- Import-Jobs koennen geloescht werden (Uebersicht + Preview) - TVDB-Validierung als Pflicht: Ohne Match wird Item als 'pending' markiert - Erkennung von "Staffel X" / "Season X" Ordnernamen fuer Serien-Zuordnung - Verhindert Ghost-Serien durch Scene-Release-Prefixes (z.B. jajunge-24) - Import-Button gesperrt solange nicht alle Items zugeordnet sind - Favicon in base.html eingebunden Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2749 lines
110 KiB
JavaScript
2749 lines
110 KiB
JavaScript
/**
|
|
* Video-Bibliothek Frontend
|
|
* Bereiche pro Scan-Pfad, CRUD, Clean, Import, TVDB-Metadaten
|
|
*/
|
|
|
|
let libraryPaths = [];
|
|
let sectionStates = {}; // pathId -> {tab, page, limit}
|
|
let activePathId = null; // null = alle anzeigen
|
|
let filterTimeout = null;
|
|
let currentSeriesId = null;
|
|
let currentMovieId = null;
|
|
let currentDetailTab = "episodes";
|
|
let currentImportJobId = null;
|
|
let cleanData = [];
|
|
|
|
// === Initialisierung ===
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
loadStats();
|
|
loadFilterPresets();
|
|
loadLibraryPaths();
|
|
});
|
|
|
|
// === Statistiken ===
|
|
|
|
function loadStats() {
|
|
fetch("/api/library/stats")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
document.getElementById("stat-videos").textContent = data.total_videos || 0;
|
|
document.getElementById("stat-series").textContent = data.total_series || 0;
|
|
document.getElementById("stat-size").textContent = formatSize(data.total_size || 0);
|
|
document.getElementById("stat-duration").textContent = formatDuration(data.total_duration || 0);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// === Library-Bereiche laden ===
|
|
|
|
function loadLibraryPaths() {
|
|
fetch("/api/library/paths")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
libraryPaths = data.paths || [];
|
|
renderPathNav();
|
|
renderLibrarySections();
|
|
})
|
|
.catch(() => {
|
|
document.getElementById("library-content").innerHTML =
|
|
'<div class="loading-msg">Fehler beim Laden der Bibliothek</div>';
|
|
});
|
|
}
|
|
|
|
// === Pfad-Navigation (links) ===
|
|
|
|
function renderPathNav() {
|
|
const nav = document.getElementById("nav-paths-list");
|
|
if (!nav) return;
|
|
const enabled = libraryPaths.filter(p => p.enabled);
|
|
if (!enabled.length) {
|
|
nav.innerHTML = '<div style="font-size:0.75rem;color:#666;padding:0.3rem">Keine Pfade</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
// "Alle" Eintrag
|
|
html += `<div class="nav-path-item ${activePathId === null ? 'active' : ''}" onclick="selectLibraryPath(null)">
|
|
<span class="nav-path-icon">📚</span>
|
|
<span class="nav-path-name">Alle</span>
|
|
</div>`;
|
|
|
|
for (const lp of enabled) {
|
|
const icon = lp.media_type === 'series' ? '🎬' : '🎦';
|
|
const isActive = activePathId === lp.id;
|
|
html += `<div class="nav-path-item ${isActive ? 'active' : ''}" onclick="selectLibraryPath(${lp.id})">
|
|
<span class="nav-path-icon">${icon}</span>
|
|
<span class="nav-path-name" title="${escapeHtml(lp.path)}">${escapeHtml(lp.name)}</span>
|
|
</div>`;
|
|
}
|
|
nav.innerHTML = html;
|
|
}
|
|
|
|
function selectLibraryPath(pathId) {
|
|
activePathId = pathId;
|
|
renderPathNav();
|
|
renderLibrarySections();
|
|
}
|
|
|
|
function renderLibrarySections() {
|
|
const container = document.getElementById("library-content");
|
|
if (!libraryPaths.length) {
|
|
container.innerHTML = '<div class="loading-msg">Keine Scan-Pfade konfiguriert. Klicke "Pfade verwalten" um zu starten.</div>';
|
|
return;
|
|
}
|
|
|
|
// Welche Pfade anzeigen?
|
|
const visiblePaths = libraryPaths.filter(lp => {
|
|
if (!lp.enabled) return false;
|
|
if (activePathId !== null && lp.id !== activePathId) return false;
|
|
return true;
|
|
});
|
|
|
|
if (!visiblePaths.length) {
|
|
container.innerHTML = '<div class="loading-msg">Kein aktiver Pfad ausgewaehlt</div>';
|
|
return;
|
|
}
|
|
|
|
let html = "";
|
|
for (const lp of visiblePaths) {
|
|
const pid = lp.id;
|
|
const isSeriesLib = lp.media_type === "series";
|
|
if (!sectionStates[pid]) {
|
|
sectionStates[pid] = {
|
|
tab: "videos",
|
|
page: 1,
|
|
limit: 50,
|
|
};
|
|
}
|
|
const st = sectionStates[pid];
|
|
|
|
html += `<div class="lib-section" id="section-${pid}">`;
|
|
html += `<div class="lib-section-header">`;
|
|
html += `<h3>${escapeHtml(lp.name)}</h3>`;
|
|
html += `<span class="text-muted" style="font-size:0.75rem">${escapeHtml(lp.path)}</span>`;
|
|
html += `<div class="lib-section-actions">`;
|
|
html += `<button class="btn-small btn-secondary" onclick="scanSinglePath(${pid})">Scannen</button>`;
|
|
html += `</div></div>`;
|
|
|
|
// Tabs - Serien-Pfad: Videos+Serien+Ordner / Film-Pfad: Videos+Filme+Ordner
|
|
html += `<div class="library-tabs" id="tabs-${pid}">`;
|
|
html += `<button class="tab-btn ${st.tab === 'videos' ? 'active' : ''}" data-tab="videos" onclick="switchSectionTab(${pid}, 'videos')">Videos</button>`;
|
|
if (isSeriesLib) {
|
|
html += `<button class="tab-btn ${st.tab === 'series' ? 'active' : ''}" data-tab="series" onclick="switchSectionTab(${pid}, 'series')">Serien</button>`;
|
|
} else {
|
|
html += `<button class="tab-btn ${st.tab === 'movies' ? 'active' : ''}" data-tab="movies" onclick="switchSectionTab(${pid}, 'movies')">Filme</button>`;
|
|
}
|
|
html += `<button class="tab-btn ${st.tab === 'browser' ? 'active' : ''}" data-tab="browser" onclick="switchSectionTab(${pid}, 'browser')">Ordner</button>`;
|
|
html += `</div>`;
|
|
|
|
// Tab-Content
|
|
html += `<div class="section-content" id="content-${pid}">`;
|
|
html += `<div class="loading-msg">Lade...</div>`;
|
|
html += `</div>`;
|
|
|
|
html += `</div>`;
|
|
}
|
|
container.innerHTML = html;
|
|
|
|
// Daten laden fuer sichtbare Bereiche
|
|
for (const lp of visiblePaths) {
|
|
loadSectionData(lp.id);
|
|
}
|
|
}
|
|
|
|
function switchSectionTab(pathId, tab) {
|
|
sectionStates[pathId].tab = tab;
|
|
sectionStates[pathId].page = 1;
|
|
// Tab-Buttons aktualisieren
|
|
const tabBar = document.getElementById("tabs-" + pathId);
|
|
if (tabBar) {
|
|
tabBar.querySelectorAll(".tab-btn").forEach(b => {
|
|
b.classList.toggle("active", b.getAttribute("data-tab") === tab);
|
|
});
|
|
}
|
|
loadSectionData(pathId);
|
|
}
|
|
|
|
function loadSectionData(pathId) {
|
|
const st = sectionStates[pathId];
|
|
switch (st.tab) {
|
|
case "videos": loadSectionVideos(pathId); break;
|
|
case "series": loadSectionSeries(pathId); break;
|
|
case "movies": loadSectionMovies(pathId); break;
|
|
case "browser": loadSectionBrowser(pathId); break;
|
|
}
|
|
}
|
|
|
|
// === Videos pro Bereich ===
|
|
|
|
function loadSectionVideos(pathId, page) {
|
|
const st = sectionStates[pathId];
|
|
if (page) st.page = page;
|
|
const params = buildFilterParams();
|
|
params.set("library_path_id", pathId);
|
|
params.set("page", st.page);
|
|
params.set("limit", st.limit);
|
|
|
|
const content = document.getElementById("content-" + pathId);
|
|
fetch("/api/library/videos?" + params.toString())
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
let html = '<div class="table-wrapper">';
|
|
html += renderVideoTable(data.items || []);
|
|
html += '</div>';
|
|
html += renderPagination(data.total || 0, data.page || 1, data.pages || 1, pathId, "videos");
|
|
content.innerHTML = html;
|
|
})
|
|
.catch(() => { content.innerHTML = '<div class="loading-msg">Fehler</div>'; });
|
|
}
|
|
|
|
// === Filme pro Bereich ===
|
|
|
|
function loadSectionMovies(pathId) {
|
|
const content = document.getElementById("content-" + pathId);
|
|
fetch("/api/library/movies-list?path_id=" + pathId)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
content.innerHTML = renderMovieGrid(data.movies || []);
|
|
})
|
|
.catch(() => { content.innerHTML = '<div class="loading-msg">Fehler</div>'; });
|
|
}
|
|
|
|
function renderMovieGrid(movies) {
|
|
if (!movies.length) return '<div class="loading-msg">Keine Filme gefunden</div>';
|
|
|
|
let html = '<div class="movie-grid">';
|
|
for (const m of movies) {
|
|
const poster = m.poster_url
|
|
? `<img src="${m.poster_url}" alt="" class="movie-poster" loading="lazy">`
|
|
: '<div class="movie-poster-placeholder">Kein Poster</div>';
|
|
const year = m.year ? `<span class="tag">${m.year}</span>` : "";
|
|
const genres = m.genres ? `<div class="movie-genres">${escapeHtml(m.genres)}</div>` : "";
|
|
const duration = m.duration_sec ? formatDuration(m.duration_sec) : "";
|
|
const size = m.total_size ? formatSize(m.total_size) : "";
|
|
const tvdbBtn = m.tvdb_id
|
|
? '<span class="tag ok">TVDB</span>'
|
|
: `<button class="btn-small btn-secondary" onclick="event.stopPropagation(); openMovieTvdbModal(${m.id}, ${escapeAttr(m.title || m.folder_name)})">TVDB zuordnen</button>`;
|
|
const overview = m.overview
|
|
? `<p class="movie-overview">${escapeHtml(m.overview.substring(0, 120))}${m.overview.length > 120 ? '...' : ''}</p>`
|
|
: "";
|
|
|
|
html += `<div class="movie-card" onclick="openMovieDetail(${m.id})">
|
|
${poster}
|
|
<div class="movie-info">
|
|
<h4 title="${escapeHtml(m.folder_path || '')}">${escapeHtml(m.title || m.folder_name)}</h4>
|
|
${genres}
|
|
${overview}
|
|
<div class="movie-meta">
|
|
${year}
|
|
${duration ? `<span class="text-muted">${duration}</span>` : ""}
|
|
${size ? `<span class="text-muted">${size}</span>` : ""}
|
|
<span class="text-muted">${m.video_count || 0} Dateien</span>
|
|
${tvdbBtn}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// === Serien pro Bereich ===
|
|
|
|
function loadSectionSeries(pathId) {
|
|
const content = document.getElementById("content-" + pathId);
|
|
fetch("/api/library/series?path_id=" + pathId)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
content.innerHTML = renderSeriesGrid(data.series || []);
|
|
})
|
|
.catch(() => { content.innerHTML = '<div class="loading-msg">Fehler</div>'; });
|
|
}
|
|
|
|
// === Ordner pro Bereich ===
|
|
|
|
let _browserLoading = false;
|
|
|
|
function loadSectionBrowser(pathId, subPath) {
|
|
// Doppelklick-Schutz: Zweiten Aufruf ignorieren solange geladen wird
|
|
if (_browserLoading) return;
|
|
_browserLoading = true;
|
|
|
|
const content = document.getElementById("content-" + pathId);
|
|
content.innerHTML = '<div class="loading-msg">Lade Ordner...</div>';
|
|
|
|
const params = new URLSearchParams();
|
|
if (subPath) params.set("path", subPath);
|
|
else {
|
|
// Basispfad der Library verwenden
|
|
const lp = libraryPaths.find(p => p.id === pathId);
|
|
if (lp) params.set("path", lp.path);
|
|
}
|
|
|
|
fetch("/api/library/browse?" + params.toString())
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
let html = renderBreadcrumb(data.breadcrumb || [], pathId);
|
|
html += renderBrowser(data.folders || [], data.videos || [], pathId);
|
|
content.innerHTML = html;
|
|
})
|
|
.catch(() => { content.innerHTML = '<div class="loading-msg">Fehler</div>'; })
|
|
.finally(() => { _browserLoading = false; });
|
|
}
|
|
|
|
// === Video-Tabelle (gemeinsam genutzt) ===
|
|
|
|
function renderVideoTable(items) {
|
|
if (!items.length) return '<div class="loading-msg">Keine Videos gefunden</div>';
|
|
|
|
let html = '<table class="data-table"><thead><tr>';
|
|
html += '<th>Dateiname</th><th>Aufl.</th><th>Codec</th><th>Audio</th>';
|
|
html += '<th>Untertitel</th><th>Groesse</th><th>Dauer</th><th>Container</th><th>Aktion</th>';
|
|
html += '</tr></thead><tbody>';
|
|
|
|
for (const v of items) {
|
|
const audioInfo = (v.audio_tracks || []).map(a => {
|
|
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
|
|
const ch = channelLayout(a.channels);
|
|
return `<span class="tag">${lang} ${ch}</span>`;
|
|
}).join(" ");
|
|
const subInfo = (v.subtitle_tracks || []).map(s =>
|
|
`<span class="tag">${(s.lang || "?").toUpperCase().substring(0, 3)}</span>`
|
|
).join(" ");
|
|
const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-";
|
|
const is10bit = v.is_10bit ? ' <span class="tag hdr">10bit</span>' : "";
|
|
|
|
const vidTitle = v.file_name || "Video";
|
|
html += `<tr>
|
|
<td class="td-name" title="${escapeHtml(v.file_path || '')}">${escapeHtml(v.file_name || "-")}</td>
|
|
<td>${res}${is10bit}</td>
|
|
<td><span class="tag codec">${v.video_codec || "-"}</span></td>
|
|
<td class="td-audio">${audioInfo || "-"}</td>
|
|
<td class="td-sub">${subInfo || "-"}</td>
|
|
<td>${formatSize(v.file_size || 0)}</td>
|
|
<td>${formatDuration(v.duration_sec || 0)}</td>
|
|
<td><span class="tag">${(v.container || "-").toUpperCase()}</span></td>
|
|
<td>
|
|
<button class="btn-small btn-play" onclick="playVideo(${v.id}, ${escapeAttr(vidTitle)})" title="Abspielen">▶</button>
|
|
<button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button>
|
|
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, ${escapeAttr(vidTitle)})" title="Loeschen">✕</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
return html;
|
|
}
|
|
|
|
function renderPagination(total, page, pages, pathId, tabType) {
|
|
let html = '<div class="pagination-row">';
|
|
html += '<div class="pagination">';
|
|
if (pages <= 1) {
|
|
html += `<span class="page-info">${total} Videos</span>`;
|
|
} else {
|
|
html += `<span class="page-info">${total} Videos | Seite ${page}/${pages}</span> `;
|
|
if (page > 1) {
|
|
html += `<button class="btn-small btn-secondary" onclick="loadSection${tabType === 'movies' ? 'Movies' : 'Videos'}(${pathId}, ${page - 1})">←</button> `;
|
|
}
|
|
if (page < pages) {
|
|
html += `<button class="btn-small btn-secondary" onclick="loadSection${tabType === 'movies' ? 'Movies' : 'Videos'}(${pathId}, ${page + 1})">→</button>`;
|
|
}
|
|
}
|
|
html += '</div>';
|
|
html += `<div class="page-limit">
|
|
<label>Eintraege:</label>
|
|
<select onchange="changeSectionLimit(${pathId}, this.value)">
|
|
<option value="25" ${sectionStates[pathId].limit === 25 ? 'selected' : ''}>25</option>
|
|
<option value="50" ${sectionStates[pathId].limit === 50 ? 'selected' : ''}>50</option>
|
|
<option value="100" ${sectionStates[pathId].limit === 100 ? 'selected' : ''}>100</option>
|
|
<option value="200" ${sectionStates[pathId].limit === 200 ? 'selected' : ''}>200</option>
|
|
</select>
|
|
</div>`;
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function changeSectionLimit(pathId, val) {
|
|
sectionStates[pathId].limit = parseInt(val) || 50;
|
|
sectionStates[pathId].page = 1;
|
|
loadSectionData(pathId);
|
|
}
|
|
|
|
// === Serien-Grid ===
|
|
|
|
function renderSeriesGrid(series) {
|
|
if (!series.length) return '<div class="loading-msg">Keine Serien gefunden</div>';
|
|
|
|
let html = '<div class="series-grid">';
|
|
for (const s of series) {
|
|
const poster = s.poster_url
|
|
? `<img src="${s.poster_url}" alt="" class="series-poster" loading="lazy">`
|
|
: '<div class="series-poster-placeholder">Kein Poster</div>';
|
|
const missing = s.missing_episodes > 0
|
|
? `<span class="status-badge warn">${s.missing_episodes} fehlend</span>` : "";
|
|
const genres = s.genres ? `<div class="series-genres">${escapeHtml(s.genres)}</div>` : "";
|
|
const tvdbBtn = s.tvdb_id
|
|
? `<span class="tag ok">TVDB</span>`
|
|
: `<button class="btn-small btn-secondary" onclick="event.stopPropagation(); openTvdbModal(${s.id}, ${escapeAttr(s.folder_name)})">TVDB zuordnen</button>`;
|
|
|
|
html += `<div class="series-card" onclick="openSeriesDetail(${s.id})">
|
|
${poster}
|
|
<div class="series-info">
|
|
<h4 title="${escapeHtml(s.folder_path || '')}">${escapeHtml(s.title || s.folder_name)}</h4>
|
|
${genres}
|
|
<div class="series-meta">
|
|
<span>${s.local_episodes || 0} Episoden</span>
|
|
${missing}
|
|
${tvdbBtn}
|
|
</div>
|
|
${s.status ? `<span class="tag">${s.status}</span>` : ""}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// === Ordner-Ansicht ===
|
|
|
|
function renderBreadcrumb(crumbs, pathId) {
|
|
let html = '<div class="browser-breadcrumb">';
|
|
html += `<a class="breadcrumb-link" href="#" onclick="loadSectionBrowser(${pathId}); return false;">Basis</a>`;
|
|
for (const c of crumbs) {
|
|
html += ' <span class="breadcrumb-sep">/</span> ';
|
|
html += `<a class="breadcrumb-link" href="#" onclick="loadSectionBrowser(${pathId}, '${escapeHtml(c.path)}'); return false;">${escapeHtml(c.name)}</a>`;
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function renderBrowser(folders, videos, pathId) {
|
|
if (!folders.length && !videos.length) return '<div class="loading-msg">Leerer Ordner</div>';
|
|
|
|
let html = "";
|
|
if (folders.length) {
|
|
html += '<div class="browser-folders">';
|
|
for (const f of folders) {
|
|
const size = formatSize(f.total_size || 0);
|
|
const pathEsc = f.path.replace(/'/g, "\\'");
|
|
html += `<div class="browser-folder">
|
|
<div class="folder-main" onclick="loadSectionBrowser(${pathId}, '${pathEsc}')">
|
|
<span class="folder-icon">📁</span>
|
|
<div class="folder-info">
|
|
<span class="folder-name">${escapeHtml(f.name)}</span>
|
|
<span class="folder-meta">${f.video_count} Videos, ${size}</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn-folder-delete" onclick="event.stopPropagation(); showDeleteFolderDialog('${pathEsc}', ${pathId}, ${f.video_count})" title="Ordner loeschen">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="3 6 5 6 21 6"/>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
<line x1="10" y1="11" x2="10" y2="17"/>
|
|
<line x1="14" y1="11" x2="14" y2="17"/>
|
|
</svg>
|
|
</button>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
if (videos.length) {
|
|
html += '<div class="browser-videos">';
|
|
html += renderVideoTable(videos);
|
|
html += '</div>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
// === Serien-Detail ===
|
|
|
|
function openSeriesDetail(seriesId) {
|
|
if (event) event.stopPropagation();
|
|
currentSeriesId = seriesId;
|
|
currentDetailTab = "episodes";
|
|
document.getElementById("series-modal").style.display = "flex";
|
|
|
|
// Tabs zuruecksetzen
|
|
document.querySelectorAll(".detail-tab").forEach(b => b.classList.remove("active"));
|
|
document.querySelector('.detail-tab[onclick*="episodes"]').classList.add("active");
|
|
|
|
fetch(`/api/library/series/${seriesId}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
document.getElementById("series-modal-title").textContent = data.title || data.folder_name;
|
|
document.getElementById("series-modal-genres").textContent = data.genres || "";
|
|
// Aktions-Buttons anzeigen
|
|
document.getElementById("btn-tvdb-refresh").style.display = data.tvdb_id ? "" : "none";
|
|
document.getElementById("btn-tvdb-unlink").style.display = data.tvdb_id ? "" : "none";
|
|
document.getElementById("btn-metadata-dl").style.display = data.tvdb_id ? "" : "none";
|
|
renderEpisodesTab(data);
|
|
})
|
|
.catch(() => {
|
|
document.getElementById("series-modal-body").innerHTML =
|
|
'<div class="loading-msg">Fehler beim Laden</div>';
|
|
});
|
|
}
|
|
|
|
function switchDetailTab(tab) {
|
|
currentDetailTab = tab;
|
|
document.querySelectorAll(".detail-tab").forEach(b => b.classList.remove("active"));
|
|
document.querySelector(`.detail-tab[onclick*="${tab}"]`).classList.add("active");
|
|
|
|
if (tab === "episodes") {
|
|
fetch(`/api/library/series/${currentSeriesId}`)
|
|
.then(r => r.json())
|
|
.then(data => renderEpisodesTab(data))
|
|
.catch(() => {});
|
|
} else if (tab === "cast") {
|
|
loadCast();
|
|
} else if (tab === "artworks") {
|
|
loadArtworks();
|
|
}
|
|
}
|
|
|
|
function renderEpisodesTab(series) {
|
|
const body = document.getElementById("series-modal-body");
|
|
let html = '<div class="series-detail-header">';
|
|
if (series.poster_url) {
|
|
html += `<img src="${series.poster_url}" alt="" class="series-detail-poster">`;
|
|
}
|
|
html += '<div class="series-detail-info">';
|
|
if (series.overview) html += `<p class="series-overview">${escapeHtml(series.overview)}</p>`;
|
|
html += '<div class="series-detail-meta">';
|
|
if (series.first_aired) html += `<span class="tag">${series.first_aired}</span>`;
|
|
if (series.status) html += `<span class="tag">${series.status}</span>`;
|
|
html += `<span class="tag">${series.local_episodes || 0} lokal</span>`;
|
|
if (series.total_episodes) html += `<span class="tag">${series.total_episodes} gesamt</span>`;
|
|
if (series.missing_episodes > 0) html += `<span class="status-badge warn">${series.missing_episodes} fehlend</span>`;
|
|
html += '</div></div></div>';
|
|
|
|
// Episoden nach Staffeln
|
|
const episodes = series.episodes || [];
|
|
const tvdbEpisodes = series.tvdb_episodes || [];
|
|
const seasons = {};
|
|
|
|
for (const ep of episodes) {
|
|
const s = ep.season_number || 0;
|
|
if (!seasons[s]) seasons[s] = {local: [], missing: []};
|
|
seasons[s].local.push(ep);
|
|
}
|
|
if (tvdbEpisodes.length) {
|
|
const localSet = new Set(episodes.map(e => `${e.season_number}-${e.episode_number}`));
|
|
for (const ep of tvdbEpisodes) {
|
|
const key = `${ep.season_number}-${ep.episode_number}`;
|
|
if (!localSet.has(key) && ep.season_number > 0) {
|
|
const s = ep.season_number;
|
|
if (!seasons[s]) seasons[s] = {local: [], missing: []};
|
|
seasons[s].missing.push(ep);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sortedSeasons = Object.keys(seasons).map(Number).sort((a, b) => a - b);
|
|
for (const sNum of sortedSeasons) {
|
|
const sData = seasons[sNum];
|
|
html += `<details class="season-details" ${sNum === sortedSeasons[0] ? "open" : ""}>`;
|
|
html += `<summary>Staffel ${sNum || "Unbekannt"} (${sData.local.length} vorhanden`;
|
|
if (sData.missing.length) html += `, <span class="text-warn">${sData.missing.length} fehlend</span>`;
|
|
html += ')</summary>';
|
|
|
|
html += '<table class="data-table season-table"><thead><tr>';
|
|
html += '<th>Nr</th><th>Titel</th><th>Aufl.</th><th>Codec</th><th>Audio</th><th>Aktion</th>';
|
|
html += '</tr></thead><tbody>';
|
|
|
|
const allEps = [];
|
|
for (const ep of sData.local) allEps.push({...ep, _type: "local"});
|
|
for (const ep of sData.missing) allEps.push({...ep, _type: "missing"});
|
|
allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0));
|
|
|
|
for (const ep of allEps) {
|
|
if (ep._type === "missing") {
|
|
html += `<tr class="row-missing">
|
|
<td>${ep.episode_number || "-"}</td>
|
|
<td>${escapeHtml(ep.episode_name || "-")}</td>
|
|
<td colspan="3" class="text-muted">Nicht vorhanden</td>
|
|
<td><span class="status-badge error">FEHLT</span></td>
|
|
</tr>`;
|
|
} else {
|
|
const audioInfo = (ep.audio_tracks || []).map(a => {
|
|
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
|
|
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
|
|
}).join(" ");
|
|
const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-";
|
|
const epTitle = ep.episode_title || ep.file_name || "Episode";
|
|
html += `<tr>
|
|
<td>${ep.episode_number || "-"}</td>
|
|
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}</td>
|
|
<td>${res}</td>
|
|
<td><span class="tag codec">${ep.video_codec || "-"}</span></td>
|
|
<td class="td-audio">${audioInfo || "-"}</td>
|
|
<td>
|
|
<button class="btn-small btn-play" onclick="playVideo(${ep.id}, ${escapeAttr(epTitle)})" title="Abspielen">▶</button>
|
|
<button class="btn-small btn-primary" onclick="convertVideo(${ep.id})">Conv</button>
|
|
<button class="btn-small btn-danger" onclick="deleteVideo(${ep.id}, ${escapeAttr(epTitle)}, 'series')" title="Loeschen">✕</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
}
|
|
html += '</tbody></table></details>';
|
|
}
|
|
|
|
if (!sortedSeasons.length) html += '<div class="loading-msg">Keine Episoden gefunden</div>';
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
function loadCast() {
|
|
const body = document.getElementById("series-modal-body");
|
|
body.innerHTML = '<div class="loading-msg">Lade Darsteller...</div>';
|
|
|
|
fetch(`/api/library/series/${currentSeriesId}/cast`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const cast = data.cast || [];
|
|
if (!cast.length) {
|
|
body.innerHTML = '<div class="loading-msg">Keine Darsteller-Daten vorhanden</div>';
|
|
return;
|
|
}
|
|
let html = '<div class="cast-grid">';
|
|
for (const c of cast) {
|
|
const img = c.person_image_url || c.image_url;
|
|
const imgTag = img
|
|
? `<img src="${img}" alt="" class="cast-photo" loading="lazy">`
|
|
: '<div class="cast-photo-placeholder">?</div>';
|
|
html += `<div class="cast-card">
|
|
${imgTag}
|
|
<div class="cast-info">
|
|
<strong>${escapeHtml(c.person_name)}</strong>
|
|
<span class="text-muted">${escapeHtml(c.character_name || "")}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
body.innerHTML = html;
|
|
})
|
|
.catch(() => { body.innerHTML = '<div class="loading-msg">Fehler</div>'; });
|
|
}
|
|
|
|
function loadArtworks() {
|
|
const body = document.getElementById("series-modal-body");
|
|
body.innerHTML = '<div class="loading-msg">Lade Bilder...</div>';
|
|
|
|
fetch(`/api/library/series/${currentSeriesId}/artworks`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const artworks = data.artworks || [];
|
|
if (!artworks.length) {
|
|
body.innerHTML = '<div class="loading-msg">Keine Bilder vorhanden</div>';
|
|
return;
|
|
}
|
|
// Nach Typ gruppieren
|
|
const groups = {};
|
|
for (const a of artworks) {
|
|
const t = a.artwork_type || "sonstige";
|
|
if (!groups[t]) groups[t] = [];
|
|
groups[t].push(a);
|
|
}
|
|
let html = '';
|
|
for (const [type, items] of Object.entries(groups)) {
|
|
html += `<h4 class="artwork-type-header">${escapeHtml(type.charAt(0).toUpperCase() + type.slice(1))} (${items.length})</h4>`;
|
|
html += '<div class="artwork-gallery">';
|
|
for (const a of items) {
|
|
const src = a.thumbnail_url || a.image_url;
|
|
if (!src) continue;
|
|
html += `<a href="${a.image_url}" target="_blank" class="artwork-item">
|
|
<img src="${src}" alt="${type}" loading="lazy">
|
|
${a.width && a.height ? `<span class="artwork-size">${a.width}x${a.height}</span>` : ""}
|
|
</a>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
body.innerHTML = html;
|
|
})
|
|
.catch(() => { body.innerHTML = '<div class="loading-msg">Fehler</div>'; });
|
|
}
|
|
|
|
function closeSeriesModal() {
|
|
document.getElementById("series-modal").style.display = "none";
|
|
currentSeriesId = null;
|
|
}
|
|
|
|
// === Serien-Aktionen ===
|
|
|
|
function tvdbRefresh() {
|
|
if (!currentSeriesId) return;
|
|
fetch(`/api/library/series/${currentSeriesId}/tvdb-refresh`, {method: "POST"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) alert("Fehler: " + data.error);
|
|
else { alert("TVDB aktualisiert: " + (data.name || "")); openSeriesDetail(currentSeriesId); }
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
function tvdbUnlink() {
|
|
if (!currentSeriesId || !confirm("TVDB-Zuordnung wirklich loesen?")) return;
|
|
fetch(`/api/library/series/${currentSeriesId}/tvdb`, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) alert("Fehler: " + data.error);
|
|
else { closeSeriesModal(); reloadAllSections(); }
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
function downloadMetadata() {
|
|
if (!currentSeriesId) return;
|
|
const btn = document.getElementById("btn-metadata-dl");
|
|
btn.textContent = "Laden...";
|
|
btn.disabled = true;
|
|
fetch(`/api/library/series/${currentSeriesId}/metadata-download`, {method: "POST"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.textContent = "Metadaten laden";
|
|
btn.disabled = false;
|
|
if (data.error) alert("Fehler: " + data.error);
|
|
else alert(`${data.downloaded || 0} Dateien heruntergeladen, ${data.errors || 0} Fehler`);
|
|
})
|
|
.catch(e => { btn.textContent = "Metadaten laden"; btn.disabled = false; alert("Fehler: " + e); });
|
|
}
|
|
|
|
function deleteSeries(withFiles) {
|
|
if (!currentSeriesId) return;
|
|
if (withFiles) {
|
|
if (!confirm("ACHTUNG: Serie komplett loeschen?\n\nAlle Dateien und Ordner werden UNWIDERRUFLICH geloescht!")) return;
|
|
if (!confirm("Wirklich sicher? Dieser Vorgang kann NICHT rueckgaengig gemacht werden!")) return;
|
|
} else {
|
|
if (!confirm("Serie aus der Datenbank loeschen?\n(Dateien bleiben erhalten)")) return;
|
|
}
|
|
const url = withFiles
|
|
? `/api/library/series/${currentSeriesId}?delete_files=1`
|
|
: `/api/library/series/${currentSeriesId}`;
|
|
fetch(url, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { alert("Fehler: " + data.error); return; }
|
|
let msg = "Serie aus DB geloescht.";
|
|
if (data.deleted_folder) msg += "\nOrdner geloescht: " + data.deleted_folder;
|
|
if (data.folder_error) msg += "\nOrdner-Fehler: " + data.folder_error;
|
|
alert(msg);
|
|
closeSeriesModal();
|
|
reloadAllSections();
|
|
loadStats();
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
// === Bestaetigungs-Dialog ===
|
|
let pendingConfirmAction = null;
|
|
|
|
function showDeleteFolderDialog(folderPath, pathId, videoCount) {
|
|
const folderName = folderPath.split('/').pop();
|
|
document.getElementById("confirm-title").textContent = "Ordner loeschen";
|
|
document.getElementById("confirm-icon").innerHTML = `
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#e74c3c" stroke-width="1.5">
|
|
<polyline points="3 6 5 6 21 6"/>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
<line x1="10" y1="11" x2="10" y2="17"/>
|
|
<line x1="14" y1="11" x2="14" y2="17"/>
|
|
</svg>`;
|
|
document.getElementById("confirm-message").innerHTML = `
|
|
<strong>${escapeHtml(folderName)}</strong><br>
|
|
wirklich loeschen?`;
|
|
document.getElementById("confirm-detail").innerHTML = `
|
|
${videoCount} Video${videoCount !== 1 ? 's' : ''} werden unwiderruflich geloescht.<br>
|
|
<span style="color:#e74c3c">Dieser Vorgang kann nicht rueckgaengig gemacht werden!</span>`;
|
|
document.getElementById("confirm-btn-ok").textContent = "Endgueltig loeschen";
|
|
document.getElementById("confirm-modal").style.display = "flex";
|
|
|
|
pendingConfirmAction = () => executeDeleteFolder(folderPath, pathId);
|
|
}
|
|
|
|
function closeConfirmModal() {
|
|
document.getElementById("confirm-modal").style.display = "none";
|
|
pendingConfirmAction = null;
|
|
}
|
|
|
|
function confirmAction() {
|
|
if (pendingConfirmAction) {
|
|
pendingConfirmAction();
|
|
}
|
|
closeConfirmModal();
|
|
}
|
|
|
|
function executeDeleteFolder(folderPath, pathId) {
|
|
fetch("/api/library/delete-folder", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({folder_path: folderPath})
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
return;
|
|
}
|
|
const msg = `${data.deleted_files || 0} Dateien geloescht`;
|
|
showToast(msg, "success");
|
|
if (pathId) loadSectionData(pathId);
|
|
loadStats();
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
function showToast(message, type = "info") {
|
|
const container = document.getElementById("toast-container");
|
|
if (!container) return;
|
|
const toast = document.createElement("div");
|
|
toast.className = `toast toast-${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
setTimeout(() => toast.classList.add("show"), 10);
|
|
setTimeout(() => {
|
|
toast.classList.remove("show");
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 4000);
|
|
}
|
|
|
|
// === Film-Detail ===
|
|
|
|
function openMovieDetail(movieId) {
|
|
if (event) event.stopPropagation();
|
|
currentMovieId = movieId;
|
|
document.getElementById("movie-modal").style.display = "flex";
|
|
|
|
fetch(`/api/library/movies/${movieId}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
document.getElementById("movie-modal-title").textContent =
|
|
data.title || data.folder_name;
|
|
document.getElementById("movie-modal-genres").textContent =
|
|
data.genres || "";
|
|
// Aktions-Buttons
|
|
document.getElementById("btn-movie-tvdb-unlink").style.display =
|
|
data.tvdb_id ? "" : "none";
|
|
|
|
let html = '<div class="movie-detail-header">';
|
|
if (data.poster_url) {
|
|
html += `<img src="${data.poster_url}" alt="" class="movie-detail-poster">`;
|
|
}
|
|
html += '<div class="movie-detail-info">';
|
|
if (data.overview) html += `<p class="series-overview">${escapeHtml(data.overview)}</p>`;
|
|
html += '<div class="series-detail-meta">';
|
|
if (data.year) html += `<span class="tag">${data.year}</span>`;
|
|
if (data.runtime) html += `<span class="tag">${data.runtime} min</span>`;
|
|
if (data.status) html += `<span class="tag">${data.status}</span>`;
|
|
html += `<span class="tag">${data.video_count || 0} Dateien</span>`;
|
|
if (data.total_size) html += `<span class="tag">${formatSize(data.total_size)}</span>`;
|
|
html += '</div></div></div>';
|
|
|
|
// Video-Dateien des Films
|
|
const videos = data.videos || [];
|
|
if (videos.length) {
|
|
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.9rem;color:#fff">Video-Dateien</h4>';
|
|
html += '<table class="data-table"><thead><tr>';
|
|
html += '<th>Datei</th><th>Aufl.</th><th>Codec</th><th>Audio</th><th>Groesse</th><th>Dauer</th><th>Aktion</th>';
|
|
html += '</tr></thead><tbody>';
|
|
for (const v of videos) {
|
|
const audioInfo = (v.audio_tracks || []).map(a => {
|
|
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
|
|
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
|
|
}).join(" ");
|
|
const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-";
|
|
const movieTitle = v.file_name || "Video";
|
|
html += `<tr>
|
|
<td class="td-name" title="${escapeHtml(v.file_path || '')}">${escapeHtml(v.file_name || "-")}</td>
|
|
<td>${res}${v.is_10bit ? ' <span class="tag hdr">10bit</span>' : ''}</td>
|
|
<td><span class="tag codec">${v.video_codec || "-"}</span></td>
|
|
<td class="td-audio">${audioInfo || "-"}</td>
|
|
<td>${formatSize(v.file_size || 0)}</td>
|
|
<td>${formatDuration(v.duration_sec || 0)}</td>
|
|
<td>
|
|
<button class="btn-small btn-play" onclick="playVideo(${v.id}, ${escapeAttr(movieTitle)})" title="Abspielen">▶</button>
|
|
<button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button>
|
|
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, ${escapeAttr(movieTitle)}, 'movie')" title="Loeschen">✕</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
}
|
|
|
|
document.getElementById("movie-modal-body").innerHTML = html;
|
|
})
|
|
.catch(() => {
|
|
document.getElementById("movie-modal-body").innerHTML =
|
|
'<div class="loading-msg">Fehler beim Laden</div>';
|
|
});
|
|
}
|
|
|
|
function closeMovieModal() {
|
|
document.getElementById("movie-modal").style.display = "none";
|
|
currentMovieId = null;
|
|
}
|
|
|
|
function movieTvdbUnlink() {
|
|
if (!currentMovieId || !confirm("TVDB-Zuordnung wirklich loesen?")) return;
|
|
fetch(`/api/library/movies/${currentMovieId}/tvdb`, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) alert("Fehler: " + data.error);
|
|
else { closeMovieModal(); reloadAllSections(); }
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
function deleteMovie(withFiles) {
|
|
if (!currentMovieId) return;
|
|
if (withFiles) {
|
|
if (!confirm("ACHTUNG: Film komplett loeschen?\n\nAlle Dateien werden UNWIDERRUFLICH geloescht!")) return;
|
|
if (!confirm("Wirklich sicher?")) return;
|
|
} else {
|
|
if (!confirm("Film aus der Datenbank loeschen?\n(Dateien bleiben erhalten)")) return;
|
|
}
|
|
const url = withFiles
|
|
? `/api/library/movies/${currentMovieId}?delete_files=1`
|
|
: `/api/library/movies/${currentMovieId}`;
|
|
fetch(url, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { alert("Fehler: " + data.error); return; }
|
|
alert("Film aus DB geloescht." + (data.deleted_folder ? "\nOrdner geloescht." : ""));
|
|
closeMovieModal();
|
|
reloadAllSections();
|
|
loadStats();
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
// === Film-TVDB-Zuordnung ===
|
|
|
|
function openMovieTvdbModal(movieId, title) {
|
|
document.getElementById("movie-tvdb-modal").style.display = "flex";
|
|
document.getElementById("movie-tvdb-id").value = movieId;
|
|
document.getElementById("movie-tvdb-search-input").value = cleanSearchTitle(title);
|
|
document.getElementById("movie-tvdb-results").innerHTML = "";
|
|
searchMovieTvdb();
|
|
}
|
|
|
|
function closeMovieTvdbModal() {
|
|
document.getElementById("movie-tvdb-modal").style.display = "none";
|
|
}
|
|
|
|
function searchMovieTvdb() {
|
|
const query = document.getElementById("movie-tvdb-search-input").value.trim();
|
|
if (!query) return;
|
|
|
|
const results = document.getElementById("movie-tvdb-results");
|
|
results.innerHTML = '<div class="loading-msg">Suche...</div>';
|
|
|
|
fetch(`/api/tvdb/search-movies?q=${encodeURIComponent(query)}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { results.innerHTML = `<div class="loading-msg">${escapeHtml(data.error)}</div>`; return; }
|
|
if (!data.results || !data.results.length) { results.innerHTML = '<div class="loading-msg">Keine Ergebnisse</div>'; return; }
|
|
results.innerHTML = data.results.map(r => `
|
|
<div class="tvdb-result" onclick="matchMovieTvdb(${r.tvdb_id})">
|
|
${r.poster ? `<img src="${r.poster}" alt="" class="tvdb-thumb">` : ""}
|
|
<div>
|
|
<strong>${escapeHtml(r.name)}</strong>
|
|
<span class="text-muted">${r.year || ""}</span>
|
|
<p class="tvdb-overview">${escapeHtml((r.overview || "").substring(0, 150))}</p>
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
})
|
|
.catch(e => { results.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
}
|
|
|
|
function matchMovieTvdb(tvdbId) {
|
|
const movieId = document.getElementById("movie-tvdb-id").value;
|
|
const results = document.getElementById("movie-tvdb-results");
|
|
results.innerHTML = '<div class="loading-msg">Verknuepfe...</div>';
|
|
|
|
fetch(`/api/library/movies/${movieId}/tvdb-match`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({tvdb_id: tvdbId}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { results.innerHTML = `<div class="loading-msg">${escapeHtml(data.error)}</div>`; }
|
|
else { closeMovieTvdbModal(); reloadAllSections(); }
|
|
})
|
|
.catch(e => { results.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
}
|
|
|
|
let movieTvdbSearchTimer = null;
|
|
function debounceMovieTvdbSearch() {
|
|
if (movieTvdbSearchTimer) clearTimeout(movieTvdbSearchTimer);
|
|
movieTvdbSearchTimer = setTimeout(searchMovieTvdb, 500);
|
|
}
|
|
|
|
// === Filter ===
|
|
|
|
let filterPresets = {};
|
|
let defaultView = "all";
|
|
|
|
function buildFilterParams() {
|
|
const params = new URLSearchParams();
|
|
const search = document.getElementById("filter-search").value.trim();
|
|
if (search) params.set("search", search);
|
|
const codec = document.getElementById("filter-codec").value;
|
|
if (codec) params.set("video_codec", codec);
|
|
const container = document.getElementById("filter-container").value;
|
|
if (container) params.set("container", container);
|
|
const audioLang = document.getElementById("filter-audio-lang").value;
|
|
if (audioLang) params.set("audio_lang", audioLang);
|
|
const audioCh = document.getElementById("filter-audio-ch").value;
|
|
if (audioCh) params.set("audio_channels", audioCh);
|
|
if (document.getElementById("filter-10bit").checked) params.set("is_10bit", "1");
|
|
if (document.getElementById("filter-not-converted").checked) params.set("not_converted", "1");
|
|
const resolution = document.getElementById("filter-resolution").value;
|
|
if (resolution) params.set("min_width", resolution);
|
|
const sort = document.getElementById("filter-sort").value;
|
|
if (sort) params.set("sort", sort);
|
|
const order = document.getElementById("filter-order").value;
|
|
if (order) params.set("order", order);
|
|
return params;
|
|
}
|
|
|
|
function applyFilters() {
|
|
// Preset-Auswahl zuruecksetzen wenn manuell gefiltert wird
|
|
document.getElementById("filter-preset").value = "";
|
|
for (const pid of Object.keys(sectionStates)) {
|
|
sectionStates[pid].page = 1;
|
|
loadSectionData(parseInt(pid));
|
|
}
|
|
}
|
|
|
|
function debounceFilter() {
|
|
if (filterTimeout) clearTimeout(filterTimeout);
|
|
filterTimeout = setTimeout(applyFilters, 400);
|
|
}
|
|
|
|
// Filter-Presets laden
|
|
async function loadFilterPresets() {
|
|
try {
|
|
const resp = await fetch("/api/library/filter-presets");
|
|
const data = await resp.json();
|
|
filterPresets = data.presets || {};
|
|
defaultView = data.default_view || "all";
|
|
|
|
// Preset-Dropdown befuellen
|
|
const select = document.getElementById("filter-preset");
|
|
// Bestehende Custom-Presets entfernen (4 feste Optionen behalten: Alle, Nicht konvertiert, Alte Formate, Fehlende Episoden)
|
|
while (select.options.length > 4) {
|
|
select.remove(4);
|
|
}
|
|
// Gespeicherte Presets hinzufuegen
|
|
for (const [id, preset] of Object.entries(filterPresets)) {
|
|
const opt = document.createElement("option");
|
|
opt.value = id;
|
|
opt.textContent = preset.name || id;
|
|
select.appendChild(opt);
|
|
}
|
|
|
|
// Standard-Ansicht anwenden
|
|
if (defaultView && defaultView !== "all") {
|
|
applyPreset(defaultView);
|
|
}
|
|
} catch (e) {
|
|
console.error("Filter-Presets laden fehlgeschlagen:", e);
|
|
}
|
|
}
|
|
|
|
// Preset anwenden
|
|
let showMissingMode = false;
|
|
|
|
function applyPreset(presetId) {
|
|
const id = presetId || document.getElementById("filter-preset").value;
|
|
|
|
// Alle Filter zuruecksetzen
|
|
resetFiltersQuiet();
|
|
showMissingMode = false;
|
|
|
|
if (!id) {
|
|
// "Alle anzeigen"
|
|
applyFilters();
|
|
return;
|
|
}
|
|
|
|
// Eingebaute Presets
|
|
if (id === "not_converted") {
|
|
document.getElementById("filter-not-converted").checked = true;
|
|
} else if (id === "old_formats") {
|
|
// Alte Formate = alles ausser AV1
|
|
document.getElementById("filter-codec").value = "";
|
|
document.getElementById("filter-not-converted").checked = true;
|
|
} else if (id === "missing_episodes") {
|
|
// Fehlende Episoden - spezieller Modus
|
|
showMissingMode = true;
|
|
loadMissingEpisodes();
|
|
document.getElementById("filter-preset").value = id;
|
|
return;
|
|
} else if (filterPresets[id]) {
|
|
// Custom Preset
|
|
const p = filterPresets[id];
|
|
if (p.video_codec) document.getElementById("filter-codec").value = p.video_codec;
|
|
if (p.container) document.getElementById("filter-container").value = p.container;
|
|
if (p.min_width) document.getElementById("filter-resolution").value = p.min_width;
|
|
if (p.audio_lang) document.getElementById("filter-audio-lang").value = p.audio_lang;
|
|
if (p.is_10bit) document.getElementById("filter-10bit").checked = true;
|
|
if (p.not_converted) document.getElementById("filter-not-converted").checked = true;
|
|
if (p.show_missing) {
|
|
showMissingMode = true;
|
|
loadMissingEpisodes();
|
|
document.getElementById("filter-preset").value = id;
|
|
return;
|
|
}
|
|
}
|
|
|
|
document.getElementById("filter-preset").value = id;
|
|
|
|
for (const pid of Object.keys(sectionStates)) {
|
|
sectionStates[pid].page = 1;
|
|
loadSectionData(parseInt(pid));
|
|
}
|
|
}
|
|
|
|
// Fehlende Episoden laden und anzeigen
|
|
let missingPage = 1;
|
|
async function loadMissingEpisodes(page = 1) {
|
|
missingPage = page;
|
|
const container = document.getElementById("library-content");
|
|
container.innerHTML = '<div class="loading-msg">Lade fehlende Episoden...</div>';
|
|
|
|
try {
|
|
const resp = await fetch(`/api/library/missing-episodes?page=${page}&limit=50`);
|
|
const data = await resp.json();
|
|
|
|
if (!data.items || data.items.length === 0) {
|
|
container.innerHTML = '<div class="loading-msg">Keine fehlenden Episoden gefunden. Alle Serien sind vollstaendig!</div>';
|
|
return;
|
|
}
|
|
|
|
// Gruppiere nach Serie
|
|
const bySeries = {};
|
|
for (const ep of data.items) {
|
|
const key = ep.series_id;
|
|
if (!bySeries[key]) {
|
|
bySeries[key] = {
|
|
series_id: ep.series_id,
|
|
series_title: ep.series_title,
|
|
poster_url: ep.poster_url,
|
|
episodes: []
|
|
};
|
|
}
|
|
bySeries[key].episodes.push(ep);
|
|
}
|
|
|
|
let html = `<div class="missing-episodes-view">
|
|
<h3>Fehlende Episoden (${data.total} insgesamt)</h3>`;
|
|
|
|
for (const series of Object.values(bySeries)) {
|
|
html += `<div class="missing-series-block">
|
|
<div class="missing-series-header">
|
|
${series.poster_url ? `<img src="${series.poster_url}" class="missing-poster" alt="">` : ''}
|
|
<h4>${series.series_title}</h4>
|
|
<span class="missing-count">${series.episodes.length} fehlend</span>
|
|
</div>
|
|
<div class="missing-episodes-list">`;
|
|
|
|
for (const ep of series.episodes) {
|
|
const aired = ep.aired ? ` (${ep.aired})` : '';
|
|
html += `<div class="missing-episode">
|
|
<span class="ep-num">S${String(ep.season_number).padStart(2,'0')}E${String(ep.episode_number).padStart(2,'0')}</span>
|
|
<span class="ep-name">${ep.episode_name || 'Unbekannt'}${aired}</span>
|
|
</div>`;
|
|
}
|
|
|
|
html += `</div></div>`;
|
|
}
|
|
|
|
// Pagination
|
|
if (data.pages > 1) {
|
|
html += '<div class="missing-pagination">';
|
|
if (page > 1) {
|
|
html += `<button class="btn-small" onclick="loadMissingEpisodes(${page - 1})">Zurueck</button>`;
|
|
}
|
|
html += ` Seite ${page} von ${data.pages} `;
|
|
if (page < data.pages) {
|
|
html += `<button class="btn-small" onclick="loadMissingEpisodes(${page + 1})">Weiter</button>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
|
|
} catch (e) {
|
|
container.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
// Filter zuruecksetzen (ohne Reload)
|
|
function resetFiltersQuiet() {
|
|
document.getElementById("filter-search").value = "";
|
|
document.getElementById("filter-codec").value = "";
|
|
document.getElementById("filter-container").value = "";
|
|
document.getElementById("filter-resolution").value = "";
|
|
document.getElementById("filter-audio-lang").value = "";
|
|
document.getElementById("filter-audio-ch").value = "";
|
|
document.getElementById("filter-10bit").checked = false;
|
|
document.getElementById("filter-not-converted").checked = false;
|
|
document.getElementById("filter-sort").value = "file_name";
|
|
document.getElementById("filter-order").value = "asc";
|
|
}
|
|
|
|
// Filter zuruecksetzen (mit Reload)
|
|
function resetFilters() {
|
|
resetFiltersQuiet();
|
|
document.getElementById("filter-preset").value = "";
|
|
applyFilters();
|
|
}
|
|
|
|
// Aktuellen Filter als Preset speichern
|
|
async function saveCurrentFilter() {
|
|
const name = prompt("Name fuer diesen Filter:");
|
|
if (!name) return;
|
|
|
|
const id = name.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
|
const filters = {};
|
|
|
|
const codec = document.getElementById("filter-codec").value;
|
|
if (codec) filters.video_codec = codec;
|
|
const container = document.getElementById("filter-container").value;
|
|
if (container) filters.container = container;
|
|
const resolution = document.getElementById("filter-resolution").value;
|
|
if (resolution) filters.min_width = resolution;
|
|
const audioLang = document.getElementById("filter-audio-lang").value;
|
|
if (audioLang) filters.audio_lang = audioLang;
|
|
if (document.getElementById("filter-10bit").checked) filters.is_10bit = true;
|
|
if (document.getElementById("filter-not-converted").checked) filters.not_converted = true;
|
|
|
|
try {
|
|
const resp = await fetch("/api/library/filter-presets", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({id, name, filters}),
|
|
});
|
|
if (resp.ok) {
|
|
showToast(`Filter "${name}" gespeichert`, "success");
|
|
loadFilterPresets();
|
|
} else {
|
|
showToast("Fehler beim Speichern", "error");
|
|
}
|
|
} catch (e) {
|
|
showToast("Fehler: " + e, "error");
|
|
}
|
|
}
|
|
|
|
// Aktuellen Filter als Standard setzen
|
|
async function setAsDefault() {
|
|
const presetId = document.getElementById("filter-preset").value || "all";
|
|
try {
|
|
const resp = await fetch("/api/library/default-view", {
|
|
method: "PUT",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({default_view: presetId}),
|
|
});
|
|
if (resp.ok) {
|
|
defaultView = presetId;
|
|
showToast("Standard-Ansicht gespeichert", "success");
|
|
} else {
|
|
showToast("Fehler beim Speichern", "error");
|
|
}
|
|
} catch (e) {
|
|
showToast("Fehler: " + e, "error");
|
|
}
|
|
}
|
|
|
|
// === Scan ===
|
|
|
|
function startScan() {
|
|
const progress = document.getElementById("scan-progress");
|
|
progress.style.display = "block";
|
|
document.getElementById("scan-status").textContent = "Scan wird gestartet...";
|
|
document.getElementById("scan-bar").style.width = "0%";
|
|
|
|
fetch("/api/library/scan", {method: "POST"})
|
|
.then(() => pollScanStatus())
|
|
.catch(e => { document.getElementById("scan-status").textContent = "Fehler: " + e; });
|
|
}
|
|
|
|
function scanSinglePath(pathId) {
|
|
const progress = document.getElementById("scan-progress");
|
|
progress.style.display = "block";
|
|
document.getElementById("scan-status").textContent = "Scan wird gestartet...";
|
|
document.getElementById("scan-bar").style.width = "0%";
|
|
|
|
fetch(`/api/library/scan/${pathId}`, {method: "POST"})
|
|
.then(() => pollScanStatus())
|
|
.catch(e => { document.getElementById("scan-status").textContent = "Fehler: " + e; });
|
|
}
|
|
|
|
function pollScanStatus() {
|
|
const interval = setInterval(() => {
|
|
fetch("/api/library/scan-status")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === "idle") {
|
|
clearInterval(interval);
|
|
document.getElementById("scan-progress").style.display = "none";
|
|
loadStats();
|
|
reloadAllSections();
|
|
} else {
|
|
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
|
|
document.getElementById("scan-bar").style.width = pct + "%";
|
|
document.getElementById("scan-status").textContent =
|
|
`Scanne: ${data.current || ""} (${data.done || 0}/${data.total || 0})`;
|
|
}
|
|
})
|
|
.catch(() => clearInterval(interval));
|
|
}, 1000);
|
|
}
|
|
|
|
function reloadAllSections() {
|
|
for (const pid of Object.keys(sectionStates)) {
|
|
loadSectionData(parseInt(pid));
|
|
}
|
|
}
|
|
|
|
// === TVDB Auto-Match (Review-Modus) ===
|
|
|
|
let tvdbReviewData = []; // Vorschlaege die noch geprueft werden muessen
|
|
|
|
function startAutoMatch() {
|
|
if (!confirm("TVDB-Vorschlaege fuer alle nicht-zugeordneten Serien und Filme sammeln?\n\nDas kann einige Minuten dauern. Du kannst danach jeden Vorschlag pruefen und bestaetigen.")) return;
|
|
|
|
const progress = document.getElementById("auto-match-progress");
|
|
progress.style.display = "block";
|
|
document.getElementById("auto-match-status").textContent = "Suche TVDB-Vorschlaege...";
|
|
document.getElementById("auto-match-bar").style.width = "0%";
|
|
|
|
fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
document.getElementById("auto-match-status").textContent = "Fehler: " + data.error;
|
|
setTimeout(() => { progress.style.display = "none"; }, 3000);
|
|
return;
|
|
}
|
|
pollAutoMatchStatus();
|
|
})
|
|
.catch(e => {
|
|
document.getElementById("auto-match-status").textContent = "Fehler: " + e;
|
|
});
|
|
}
|
|
|
|
function pollAutoMatchStatus() {
|
|
const progress = document.getElementById("auto-match-progress");
|
|
const interval = setInterval(() => {
|
|
fetch("/api/library/tvdb-auto-match-status")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const bar = document.getElementById("auto-match-bar");
|
|
const status = document.getElementById("auto-match-status");
|
|
|
|
if (data.phase === "done") {
|
|
clearInterval(interval);
|
|
bar.style.width = "100%";
|
|
const suggestions = data.suggestions || [];
|
|
// Nur Items mit mindestens einem Vorschlag anzeigen
|
|
const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0);
|
|
const noResults = suggestions.length - withSuggestions.length;
|
|
status.textContent = `${withSuggestions.length} Vorschlaege gefunden, ${noResults} ohne Ergebnis`;
|
|
|
|
setTimeout(() => {
|
|
progress.style.display = "none";
|
|
}, 2000);
|
|
|
|
if (withSuggestions.length > 0) {
|
|
openTvdbReviewModal(withSuggestions);
|
|
}
|
|
} else if (data.phase === "error") {
|
|
clearInterval(interval);
|
|
status.textContent = "Fehler beim Sammeln der Vorschlaege";
|
|
setTimeout(() => { progress.style.display = "none"; }, 3000);
|
|
} else if (!data.active && data.phase !== "done") {
|
|
clearInterval(interval);
|
|
progress.style.display = "none";
|
|
} else {
|
|
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
|
|
bar.style.width = pct + "%";
|
|
const phase = data.phase === "series" ? "Serien" : "Filme";
|
|
status.textContent = `${phase}: ${data.current || ""} (${data.done}/${data.total})`;
|
|
}
|
|
})
|
|
.catch(() => clearInterval(interval));
|
|
}, 1000);
|
|
}
|
|
|
|
// === TVDB Review-Modal ===
|
|
|
|
function openTvdbReviewModal(suggestions) {
|
|
tvdbReviewData = suggestions;
|
|
document.getElementById("tvdb-review-modal").style.display = "flex";
|
|
renderTvdbReviewList();
|
|
}
|
|
|
|
function closeTvdbReviewModal() {
|
|
document.getElementById("tvdb-review-modal").style.display = "none";
|
|
tvdbReviewData = [];
|
|
reloadAllSections();
|
|
loadStats();
|
|
}
|
|
|
|
function renderTvdbReviewList() {
|
|
const list = document.getElementById("tvdb-review-list");
|
|
const remaining = tvdbReviewData.filter(item => !item._confirmed && !item._skipped);
|
|
const confirmed = tvdbReviewData.filter(item => item._confirmed);
|
|
const skipped = tvdbReviewData.filter(item => item._skipped);
|
|
|
|
document.getElementById("tvdb-review-info").textContent =
|
|
`${remaining.length} offen, ${confirmed.length} zugeordnet, ${skipped.length} uebersprungen`;
|
|
|
|
if (!tvdbReviewData.length) {
|
|
list.innerHTML = '<div class="loading-msg">Keine Vorschlaege vorhanden</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
for (let i = 0; i < tvdbReviewData.length; i++) {
|
|
const item = tvdbReviewData[i];
|
|
const typeLabel = item.type === "series" ? "Serie" : "Film";
|
|
const typeClass = item.type === "series" ? "tag-series" : "tag-movie";
|
|
|
|
// Status-Klasse
|
|
let statusClass = "";
|
|
let statusHtml = "";
|
|
if (item._confirmed) {
|
|
statusClass = "review-item-done";
|
|
statusHtml = `<span class="status-badge ok">Zugeordnet: ${escapeHtml(item._confirmedName || "")}</span>`;
|
|
} else if (item._skipped) {
|
|
statusClass = "review-item-skipped";
|
|
statusHtml = '<span class="status-badge">Uebersprungen</span>';
|
|
}
|
|
|
|
html += `<div class="review-item ${statusClass}" id="review-item-${i}">`;
|
|
html += `<div class="review-item-header">`;
|
|
html += `<span class="tag ${typeClass}">${typeLabel}</span>`;
|
|
html += `<strong class="review-local-name">${escapeHtml(item.local_name)}</strong>`;
|
|
if (item.year) html += `<span class="text-muted">(${item.year})</span>`;
|
|
if (statusHtml) html += statusHtml;
|
|
if (!item._confirmed && !item._skipped) {
|
|
html += `<button class="btn-small btn-secondary review-skip-btn" onclick="skipReviewItem(${i})">Ueberspringen</button>`;
|
|
html += `<button class="btn-small btn-secondary review-search-btn" onclick="manualTvdbSearchReview(${i})">Manuell suchen</button>`;
|
|
}
|
|
html += `</div>`;
|
|
|
|
// Vorschlaege anzeigen (nur wenn noch nicht bestaetigt/uebersprungen)
|
|
if (!item._confirmed && !item._skipped) {
|
|
html += `<div class="review-suggestions">`;
|
|
if (!item.suggestions || !item.suggestions.length) {
|
|
html += '<span class="text-muted">Keine Vorschlaege gefunden</span>';
|
|
} else {
|
|
for (const s of item.suggestions) {
|
|
const poster = s.poster
|
|
? `<img src="${s.poster}" alt="" class="review-poster" loading="lazy">`
|
|
: '<div class="review-poster-placeholder">?</div>';
|
|
html += `<div class="review-suggestion" onclick="confirmReviewItem(${i}, ${s.tvdb_id}, ${escapeAttr(s.name)})">`;
|
|
html += poster;
|
|
html += `<div class="review-suggestion-info">`;
|
|
html += `<strong>${escapeHtml(s.name)}</strong>`;
|
|
if (s.year) html += ` <span class="text-muted">(${s.year})</span>`;
|
|
if (s.overview) html += `<p class="review-overview">${escapeHtml(s.overview)}</p>`;
|
|
html += `</div>`;
|
|
html += `</div>`;
|
|
}
|
|
}
|
|
// Manuelles Suchfeld (versteckt, wird bei Klick auf "Manuell suchen" angezeigt)
|
|
html += `<div class="review-manual-search" id="review-manual-${i}" style="display:none">`;
|
|
html += `<input type="text" class="review-search-input" id="review-search-input-${i}" placeholder="${item.type === 'series' ? 'Serienname' : 'Filmname'}..." value="${escapeHtml(item.local_name)}" onkeydown="if(event.key==='Enter')executeManualReviewSearch(${i})">`;
|
|
html += `<button class="btn-small btn-primary" onclick="executeManualReviewSearch(${i})">Suchen</button>`;
|
|
html += `<div id="review-search-results-${i}" class="review-search-results"></div>`;
|
|
html += `</div>`;
|
|
html += `</div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
}
|
|
list.innerHTML = html;
|
|
}
|
|
|
|
function confirmReviewItem(index, tvdbId, name) {
|
|
const item = tvdbReviewData[index];
|
|
if (item._confirmed || item._skipped) return;
|
|
|
|
// Visuelles Feedback
|
|
const el = document.getElementById("review-item-" + index);
|
|
if (el) el.classList.add("review-item-loading");
|
|
|
|
fetch("/api/library/tvdb-confirm", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({id: item.id, type: item.type, tvdb_id: tvdbId}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert("Fehler: " + data.error);
|
|
if (el) el.classList.remove("review-item-loading");
|
|
return;
|
|
}
|
|
item._confirmed = true;
|
|
item._confirmedName = data.name || name;
|
|
renderTvdbReviewList();
|
|
})
|
|
.catch(e => {
|
|
alert("Fehler: " + e);
|
|
if (el) el.classList.remove("review-item-loading");
|
|
});
|
|
}
|
|
|
|
function skipReviewItem(index) {
|
|
tvdbReviewData[index]._skipped = true;
|
|
renderTvdbReviewList();
|
|
}
|
|
|
|
function skipAllReviewItems() {
|
|
for (const item of tvdbReviewData) {
|
|
if (!item._confirmed) item._skipped = true;
|
|
}
|
|
renderTvdbReviewList();
|
|
}
|
|
|
|
function manualTvdbSearchReview(index) {
|
|
const el = document.getElementById("review-manual-" + index);
|
|
if (el) {
|
|
el.style.display = el.style.display === "none" ? "flex" : "none";
|
|
}
|
|
}
|
|
|
|
function executeManualReviewSearch(index) {
|
|
const item = tvdbReviewData[index];
|
|
const input = document.getElementById("review-search-input-" + index);
|
|
const results = document.getElementById("review-search-results-" + index);
|
|
const query = input ? input.value.trim() : "";
|
|
if (!query) return;
|
|
|
|
results.innerHTML = '<div class="loading-msg" style="padding:0.3rem">Suche...</div>';
|
|
|
|
const endpoint = item.type === "series"
|
|
? `/api/tvdb/search?q=${encodeURIComponent(query)}`
|
|
: `/api/tvdb/search-movies?q=${encodeURIComponent(query)}`;
|
|
|
|
fetch(endpoint)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
results.innerHTML = `<div class="loading-msg">${escapeHtml(data.error)}</div>`;
|
|
return;
|
|
}
|
|
const list = data.results || [];
|
|
if (!list.length) {
|
|
results.innerHTML = '<div class="loading-msg">Keine Ergebnisse</div>';
|
|
return;
|
|
}
|
|
results.innerHTML = list.map(r => `
|
|
<div class="review-suggestion" onclick="confirmReviewItem(${index}, ${r.tvdb_id}, ${escapeAttr(r.name)})">
|
|
${r.poster ? `<img src="${r.poster}" alt="" class="review-poster" loading="lazy">` : '<div class="review-poster-placeholder">?</div>'}
|
|
<div class="review-suggestion-info">
|
|
<strong>${escapeHtml(r.name)}</strong>
|
|
${r.year ? `<span class="text-muted">(${r.year})</span>` : ""}
|
|
<p class="review-overview">${escapeHtml((r.overview || "").substring(0, 120))}</p>
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
})
|
|
.catch(e => { results.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
}
|
|
|
|
// === TVDB Modal ===
|
|
|
|
function openTvdbModal(seriesId, folderName) {
|
|
document.getElementById("tvdb-modal").style.display = "flex";
|
|
document.getElementById("tvdb-series-id").value = seriesId;
|
|
document.getElementById("tvdb-search-input").value = cleanSearchTitle(folderName);
|
|
document.getElementById("tvdb-results").innerHTML = "";
|
|
// Checkbox zuruecksetzen
|
|
const engCheckbox = document.getElementById("tvdb-search-english");
|
|
if (engCheckbox) engCheckbox.checked = false;
|
|
searchTvdb();
|
|
}
|
|
|
|
function closeTvdbModal() {
|
|
document.getElementById("tvdb-modal").style.display = "none";
|
|
}
|
|
|
|
function searchTvdb() {
|
|
const query = document.getElementById("tvdb-search-input").value.trim();
|
|
if (!query) return;
|
|
|
|
const results = document.getElementById("tvdb-results");
|
|
results.innerHTML = '<div class="loading-msg">Suche...</div>';
|
|
|
|
// Sprache: eng wenn Checkbox aktiv, sonst Standard (deu)
|
|
const useEnglish = document.getElementById("tvdb-search-english")?.checked;
|
|
const langParam = useEnglish ? "&lang=eng" : "";
|
|
|
|
fetch(`/api/tvdb/search?q=${encodeURIComponent(query)}${langParam}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { results.innerHTML = `<div class="loading-msg">${escapeHtml(data.error)}</div>`; return; }
|
|
if (!data.results || !data.results.length) { results.innerHTML = '<div class="loading-msg">Keine Ergebnisse</div>'; return; }
|
|
results.innerHTML = data.results.map(r => {
|
|
// Zeige Original-Namen wenn vorhanden und unterschiedlich
|
|
const origName = r.original_name && r.original_name !== r.name
|
|
? `<span class="text-muted" style="font-size:0.85em">(${escapeHtml(r.original_name)})</span>`
|
|
: "";
|
|
return `
|
|
<div class="tvdb-result" onclick="matchTvdb(${r.tvdb_id})">
|
|
${r.poster ? `<img src="${r.poster}" alt="" class="tvdb-thumb">` : ""}
|
|
<div>
|
|
<strong>${escapeHtml(r.name)}</strong> ${origName}
|
|
<span class="text-muted">${r.year || ""}</span>
|
|
<p class="tvdb-overview">${escapeHtml((r.overview || "").substring(0, 150))}</p>
|
|
</div>
|
|
</div>
|
|
`}).join("");
|
|
})
|
|
.catch(e => { results.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
}
|
|
|
|
function matchTvdb(tvdbId) {
|
|
const seriesId = document.getElementById("tvdb-series-id").value;
|
|
const results = document.getElementById("tvdb-results");
|
|
results.innerHTML = '<div class="loading-msg">Verknuepfe...</div>';
|
|
|
|
fetch(`/api/library/series/${seriesId}/tvdb-match`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({tvdb_id: tvdbId}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { results.innerHTML = `<div class="loading-msg">${escapeHtml(data.error)}</div>`; }
|
|
else { closeTvdbModal(); reloadAllSections(); }
|
|
})
|
|
.catch(e => { results.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
}
|
|
|
|
let tvdbSearchTimer = null;
|
|
function debounceTvdbSearch() {
|
|
if (tvdbSearchTimer) clearTimeout(tvdbSearchTimer);
|
|
tvdbSearchTimer = setTimeout(searchTvdb, 500);
|
|
}
|
|
|
|
// === Konvertierung ===
|
|
|
|
function convertVideo(videoId) {
|
|
if (event) event.stopPropagation();
|
|
if (!confirm("Video zur Konvertierung senden?")) return;
|
|
|
|
fetch(`/api/library/videos/${videoId}/convert`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) alert("Fehler: " + data.error);
|
|
else alert("Job erstellt: " + (data.message || "OK"));
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
// === Serie komplett konvertieren ===
|
|
|
|
function openConvertSeriesModal() {
|
|
if (!currentSeriesId) return;
|
|
|
|
document.getElementById("convert-series-modal").style.display = "flex";
|
|
document.getElementById("convert-series-status").innerHTML =
|
|
'<div class="loading-msg">Lade Codec-Status...</div>';
|
|
|
|
// Codec-Status laden
|
|
fetch(`/api/library/series/${currentSeriesId}/convert-status`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
document.getElementById("convert-series-status").innerHTML =
|
|
`<div class="loading-msg">${escapeHtml(data.error)}</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = `<div style="margin-bottom:0.5rem"><strong>${data.total} Episoden</strong></div>`;
|
|
html += '<div class="codec-stats">';
|
|
for (const [codec, count] of Object.entries(data.codec_counts || {})) {
|
|
const isTarget = codec.includes("av1") || codec.includes("hevc");
|
|
const cls = isTarget ? "tag ok" : "tag";
|
|
html += `<span class="${cls}">${codec}: ${count}</span> `;
|
|
}
|
|
html += '</div>';
|
|
document.getElementById("convert-series-status").innerHTML = html;
|
|
})
|
|
.catch(e => {
|
|
document.getElementById("convert-series-status").innerHTML =
|
|
`<div class="loading-msg">Fehler: ${e}</div>`;
|
|
});
|
|
}
|
|
|
|
function closeConvertSeriesModal() {
|
|
document.getElementById("convert-series-modal").style.display = "none";
|
|
}
|
|
|
|
function executeConvertSeries() {
|
|
if (!currentSeriesId) return;
|
|
|
|
const targetCodec = document.getElementById("convert-target-codec").value;
|
|
const forceAll = document.getElementById("convert-force-all").checked;
|
|
const deleteOld = document.getElementById("convert-delete-old").checked;
|
|
|
|
const btn = document.querySelector("#convert-series-modal .btn-primary");
|
|
btn.textContent = "Wird gestartet...";
|
|
btn.disabled = true;
|
|
|
|
fetch(`/api/library/series/${currentSeriesId}/convert`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
target_codec: targetCodec,
|
|
force_all: forceAll,
|
|
delete_old: deleteOld,
|
|
}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.textContent = "Konvertierung starten";
|
|
btn.disabled = false;
|
|
|
|
if (data.error) {
|
|
alert("Fehler: " + data.error);
|
|
return;
|
|
}
|
|
|
|
let msg = data.message || "Konvertierung gestartet";
|
|
if (data.already_done > 0) {
|
|
msg += `\n${data.already_done} Episoden sind bereits im Zielformat.`;
|
|
}
|
|
alert(msg);
|
|
closeConvertSeriesModal();
|
|
})
|
|
.catch(e => {
|
|
btn.textContent = "Konvertierung starten";
|
|
btn.disabled = false;
|
|
alert("Fehler: " + e);
|
|
});
|
|
}
|
|
|
|
// === Serien-Ordner aufraeumen ===
|
|
|
|
function cleanupSeriesFolder() {
|
|
if (!currentSeriesId) return;
|
|
|
|
if (!confirm("Alle Dateien im Serien-Ordner loeschen, die NICHT in der Bibliothek sind?\n\n" +
|
|
"Behalten werden:\n" +
|
|
"- Alle Videos in der Bibliothek\n" +
|
|
"- .metadata Ordner\n" +
|
|
"- .nfo, .jpg, .png Dateien\n\n" +
|
|
"ACHTUNG: Dies kann nicht rueckgaengig gemacht werden!")) {
|
|
return;
|
|
}
|
|
|
|
fetch(`/api/library/series/${currentSeriesId}/cleanup`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert("Fehler: " + data.error);
|
|
return;
|
|
}
|
|
let msg = `${data.deleted} Dateien geloescht.`;
|
|
if (data.errors > 0) {
|
|
msg += `\n${data.errors} Fehler.`;
|
|
}
|
|
alert(msg);
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
// === Duplikate ===
|
|
|
|
function showDuplicates() {
|
|
document.getElementById("duplicates-modal").style.display = "flex";
|
|
const list = document.getElementById("duplicates-list");
|
|
list.innerHTML = '<div class="loading-msg">Suche Duplikate...</div>';
|
|
|
|
fetch("/api/library/duplicates")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const dupes = data.duplicates || [];
|
|
if (!dupes.length) { list.innerHTML = '<div class="loading-msg">Keine Duplikate gefunden</div>'; return; }
|
|
list.innerHTML = dupes.map(d => `
|
|
<div class="duplicate-pair">
|
|
<div class="dupe-item">
|
|
<span class="dupe-name">${escapeHtml(d.name1)}</span>
|
|
<span class="tag codec">${d.codec1}</span>
|
|
<span class="tag">${d.width1}x${d.height1}</span>
|
|
<span class="text-muted">${formatSize(d.size1)}</span>
|
|
</div>
|
|
<div class="dupe-vs">vs</div>
|
|
<div class="dupe-item">
|
|
<span class="dupe-name">${escapeHtml(d.name2)}</span>
|
|
<span class="tag codec">${d.codec2}</span>
|
|
<span class="tag">${d.width2}x${d.height2}</span>
|
|
<span class="text-muted">${formatSize(d.size2)}</span>
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
})
|
|
.catch(e => { list.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
}
|
|
|
|
function closeDuplicatesModal() {
|
|
document.getElementById("duplicates-modal").style.display = "none";
|
|
}
|
|
|
|
// === Pfade-Verwaltung ===
|
|
|
|
function openPathsModal() {
|
|
document.getElementById("paths-modal").style.display = "flex";
|
|
loadPathsList();
|
|
}
|
|
|
|
function closePathsModal() {
|
|
document.getElementById("paths-modal").style.display = "none";
|
|
}
|
|
|
|
function loadPathsList() {
|
|
fetch("/api/library/paths")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const paths = data.paths || [];
|
|
const list = document.getElementById("paths-list");
|
|
if (!paths.length) {
|
|
list.innerHTML = '<div class="loading-msg">Keine Pfade konfiguriert</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = paths.map(p => `
|
|
<div class="path-item">
|
|
<div class="path-info">
|
|
<strong>${escapeHtml(p.name)}</strong>
|
|
<span class="text-muted" style="font-size:0.75rem">${escapeHtml(p.path)}</span>
|
|
<span class="tag">${p.media_type === 'series' ? 'Serien' : 'Filme'}</span>
|
|
${p.enabled ? '<span class="tag ok">Aktiv</span>' : '<span class="tag">Deaktiviert</span>'}
|
|
${p.video_count !== undefined ? `<span class="text-muted" style="font-size:0.75rem">${p.video_count} Videos</span>` : ""}
|
|
</div>
|
|
<div class="path-actions">
|
|
<button class="btn-small btn-secondary" onclick="togglePath(${p.id}, ${!p.enabled})">${p.enabled ? 'Deaktivieren' : 'Aktivieren'}</button>
|
|
<button class="btn-small btn-danger" onclick="deletePath(${p.id})">Loeschen</button>
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function addPath() {
|
|
const name = document.getElementById("new-path-name").value.trim();
|
|
const path = document.getElementById("new-path-path").value.trim();
|
|
const media_type = document.getElementById("new-path-type").value;
|
|
if (!name || !path) { alert("Name und Pfad erforderlich"); return; }
|
|
|
|
fetch("/api/library/paths", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({name, path, media_type}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) alert("Fehler: " + data.error);
|
|
else {
|
|
document.getElementById("new-path-name").value = "";
|
|
document.getElementById("new-path-path").value = "";
|
|
loadPathsList();
|
|
loadLibraryPaths();
|
|
}
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
function togglePath(pathId, enabled) {
|
|
fetch(`/api/library/paths/${pathId}`, {
|
|
method: "PUT",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({enabled}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) alert("Fehler: " + data.error);
|
|
else { loadPathsList(); loadLibraryPaths(); }
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
function deletePath(pathId) {
|
|
if (!confirm("Pfad wirklich loeschen? (Videos bleiben erhalten, aber werden aus DB entfernt)")) return;
|
|
fetch(`/api/library/paths/${pathId}`, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) alert("Fehler: " + data.error);
|
|
else { loadPathsList(); loadLibraryPaths(); }
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
// === Clean-Modal ===
|
|
|
|
function openCleanModal() {
|
|
document.getElementById("clean-modal").style.display = "flex";
|
|
document.getElementById("clean-list").innerHTML =
|
|
'<div class="loading-msg">Klicke "Junk scannen" um zu starten</div>';
|
|
document.getElementById("clean-info").textContent = "";
|
|
cleanData = [];
|
|
}
|
|
|
|
function closeCleanModal() {
|
|
document.getElementById("clean-modal").style.display = "none";
|
|
}
|
|
|
|
function scanForJunk() {
|
|
const list = document.getElementById("clean-list");
|
|
list.innerHTML = '<div class="loading-msg">Scanne...</div>';
|
|
|
|
fetch("/api/library/clean/scan")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
cleanData = data.files || [];
|
|
document.getElementById("clean-info").textContent =
|
|
`${data.total_count || 0} Dateien, ${formatSize(data.total_size || 0)}`;
|
|
|
|
// Extensions-Filter fuellen
|
|
const exts = new Set(cleanData.map(f => f.extension));
|
|
const select = document.getElementById("clean-ext-filter");
|
|
select.innerHTML = '<option value="">Alle</option>';
|
|
for (const ext of [...exts].sort()) {
|
|
select.innerHTML += `<option value="${ext}">${ext}</option>`;
|
|
}
|
|
|
|
renderCleanList(cleanData);
|
|
})
|
|
.catch(e => { list.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
}
|
|
|
|
function renderCleanList(files) {
|
|
const list = document.getElementById("clean-list");
|
|
if (!files.length) {
|
|
list.innerHTML = '<div class="loading-msg">Keine Junk-Dateien gefunden</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table class="data-table"><thead><tr>';
|
|
html += '<th><input type="checkbox" id="clean-select-all-header" onchange="toggleCleanSelectAll()"></th>';
|
|
html += '<th>Dateiname</th><th>Serie/Ordner</th><th>Extension</th><th>Groesse</th>';
|
|
html += '</tr></thead><tbody>';
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const f = files[i];
|
|
html += `<tr>
|
|
<td><input type="checkbox" class="clean-check" data-idx="${i}"></td>
|
|
<td class="td-name" title="${escapeHtml(f.path)}">${escapeHtml(f.name)}</td>
|
|
<td>${escapeHtml(f.parent_series || "-")}</td>
|
|
<td><span class="tag">${escapeHtml(f.extension)}</span></td>
|
|
<td>${formatSize(f.size)}</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
list.innerHTML = html;
|
|
}
|
|
|
|
function filterCleanList() {
|
|
const ext = document.getElementById("clean-ext-filter").value;
|
|
if (!ext) { renderCleanList(cleanData); return; }
|
|
renderCleanList(cleanData.filter(f => f.extension === ext));
|
|
}
|
|
|
|
function toggleCleanSelectAll() {
|
|
const checked = document.getElementById("clean-select-all")?.checked
|
|
|| document.getElementById("clean-select-all-header")?.checked || false;
|
|
document.querySelectorAll(".clean-check").forEach(cb => cb.checked = checked);
|
|
}
|
|
|
|
function deleteSelectedJunk() {
|
|
const checked = document.querySelectorAll(".clean-check:checked");
|
|
if (!checked.length) { alert("Keine Dateien ausgewaehlt"); return; }
|
|
if (!confirm(`${checked.length} Dateien wirklich loeschen?`)) return;
|
|
|
|
const files = [];
|
|
checked.forEach(cb => {
|
|
const idx = parseInt(cb.getAttribute("data-idx"));
|
|
if (cleanData[idx]) files.push(cleanData[idx].path);
|
|
});
|
|
|
|
fetch("/api/library/clean/delete", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({files}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
alert(`${data.deleted || 0} geloescht, ${data.failed || 0} fehlgeschlagen`);
|
|
if (data.errors && data.errors.length) console.warn("Clean-Fehler:", data.errors);
|
|
scanForJunk();
|
|
})
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
function deleteEmptyDirs() {
|
|
fetch("/api/library/clean/empty-dirs", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => alert(`${data.deleted_dirs || 0} leere Ordner geloescht`))
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
// === Import-Modal ===
|
|
|
|
let importBrowseTimer = null;
|
|
|
|
function openImportModal() {
|
|
document.getElementById("import-modal").style.display = "flex";
|
|
document.getElementById("import-setup").style.display = "";
|
|
document.getElementById("import-preview").style.display = "none";
|
|
document.getElementById("import-progress").style.display = "none";
|
|
document.getElementById("import-source").value = "";
|
|
document.getElementById("import-folder-info").textContent = "";
|
|
document.getElementById("btn-analyze-import").disabled = true;
|
|
currentImportJobId = null;
|
|
|
|
// Ziel-Libraries laden
|
|
fetch("/api/library/paths")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const select = document.getElementById("import-target");
|
|
select.innerHTML = (data.paths || []).map(p =>
|
|
`<option value="${p.id}">${escapeHtml(p.name)} (${p.media_type === 'series' ? 'Serien' : 'Filme'})</option>`
|
|
).join("");
|
|
})
|
|
.catch(() => {});
|
|
|
|
// Bestehende Import-Jobs laden
|
|
loadExistingImportJobs();
|
|
|
|
// Standard-Pfad im Filebrowser oeffnen
|
|
importBrowse("/mnt");
|
|
}
|
|
|
|
function loadExistingImportJobs() {
|
|
fetch("/api/library/import")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const jobs = (data.jobs || []).filter(j => j.status !== 'done');
|
|
const container = document.getElementById("import-existing");
|
|
const list = document.getElementById("import-jobs-list");
|
|
|
|
if (!jobs.length) {
|
|
container.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
container.style.display = "";
|
|
list.innerHTML = jobs.map(j => {
|
|
const statusClass = j.status === 'ready' ? 'tag-success' :
|
|
j.status === 'error' ? 'tag-error' :
|
|
j.status === 'importing' ? 'tag-warning' : '';
|
|
const statusText = j.status === 'ready' ? 'Bereit' :
|
|
j.status === 'analyzing' ? 'Analyse...' :
|
|
j.status === 'importing' ? 'Laeuft' :
|
|
j.status === 'error' ? 'Fehler' : j.status;
|
|
const sourceName = j.source_path.split('/').pop();
|
|
return `<span style="display:inline-flex;align-items:center;gap:0.25rem">
|
|
<button class="btn-small ${j.status === 'ready' ? 'btn-primary' : 'btn-secondary'}"
|
|
onclick="loadImportJob(${j.id})"
|
|
title="${escapeHtml(j.source_path)}">
|
|
${escapeHtml(sourceName)} (${j.processed_files}/${j.total_files})
|
|
<span class="tag ${statusClass}" style="margin-left:0.3rem;font-size:0.7rem">${statusText}</span>
|
|
</button>
|
|
${j.status !== 'importing' ? `<button class="btn-small btn-danger" onclick="deleteImportJob(${j.id}, event)" title="Job loeschen">×</button>` : ''}
|
|
</span>`;
|
|
}).join("");
|
|
})
|
|
.catch(() => {
|
|
document.getElementById("import-existing").style.display = "none";
|
|
});
|
|
}
|
|
|
|
function deleteImportJob(jobId, ev) {
|
|
if (ev) ev.stopPropagation();
|
|
if (!confirm("Import-Job wirklich loeschen?")) return;
|
|
fetch(`/api/library/import/${jobId}`, { method: "DELETE" })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert("Fehler: " + data.error);
|
|
return;
|
|
}
|
|
loadExistingImportJobs();
|
|
})
|
|
.catch(() => alert("Loeschen fehlgeschlagen"));
|
|
}
|
|
|
|
function deleteCurrentImportJob() {
|
|
if (!currentImportJobId) return;
|
|
if (!confirm("Import-Job wirklich loeschen?")) return;
|
|
fetch(`/api/library/import/${currentImportJobId}`, { method: "DELETE" })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert("Fehler: " + data.error);
|
|
return;
|
|
}
|
|
resetImport();
|
|
})
|
|
.catch(() => alert("Loeschen fehlgeschlagen"));
|
|
}
|
|
|
|
function loadImportJob(jobId) {
|
|
currentImportJobId = jobId;
|
|
document.getElementById("import-setup").style.display = "none";
|
|
document.getElementById("import-existing").style.display = "none";
|
|
document.getElementById("import-preview").style.display = "";
|
|
|
|
fetch(`/api/library/import/${jobId}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert("Fehler: " + data.error);
|
|
resetImport();
|
|
return;
|
|
}
|
|
renderImportItems(data);
|
|
|
|
// Falls Job bereits laeuft, Polling starten
|
|
if (data.job && data.job.status === 'importing') {
|
|
document.getElementById("import-preview").style.display = "none";
|
|
document.getElementById("import-progress").style.display = "";
|
|
startImportPolling();
|
|
}
|
|
})
|
|
.catch(e => {
|
|
alert("Fehler beim Laden: " + e);
|
|
resetImport();
|
|
});
|
|
}
|
|
|
|
function closeImportModal() {
|
|
document.getElementById("import-modal").style.display = "none";
|
|
}
|
|
|
|
function resetImport() {
|
|
document.getElementById("import-setup").style.display = "";
|
|
document.getElementById("import-preview").style.display = "none";
|
|
document.getElementById("import-progress").style.display = "none";
|
|
currentImportJobId = null;
|
|
}
|
|
|
|
function debounceImportPath() {
|
|
if (importBrowseTimer) clearTimeout(importBrowseTimer);
|
|
importBrowseTimer = setTimeout(() => {
|
|
const val = document.getElementById("import-source").value.trim();
|
|
if (val && val.startsWith("/")) importBrowse(val);
|
|
}, 600);
|
|
}
|
|
|
|
function importBrowse(path) {
|
|
const browser = document.getElementById("import-browser");
|
|
browser.innerHTML = '<div class="loading-msg" style="padding:0.8rem">Lade...</div>';
|
|
|
|
fetch("/api/library/browse-fs?path=" + encodeURIComponent(path))
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
browser.innerHTML = `<div class="loading-msg" style="padding:0.8rem">${escapeHtml(data.error)}</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
|
|
// Breadcrumb
|
|
html += '<div class="fb-breadcrumb">';
|
|
html += `<a href="#" onclick="importBrowse('/mnt'); return false;">/mnt</a>`;
|
|
for (const c of (data.breadcrumb || [])) {
|
|
if (c.path === "/mnt" || c.path.length < 5) continue;
|
|
html += ` <span style="color:#555">/</span> `;
|
|
html += `<a href="#" onclick="importBrowse('${escapeHtml(c.path)}'); return false;">${escapeHtml(c.name)}</a>`;
|
|
}
|
|
html += '</div>';
|
|
|
|
// Eltern-Ordner
|
|
const parts = data.current_path.split("/");
|
|
if (parts.length > 2) {
|
|
const parentPath = parts.slice(0, -1).join("/") || "/mnt";
|
|
html += `<div class="import-browser-folder" onclick="importBrowse('${escapeHtml(parentPath)}')">
|
|
<span class="fb-icon">🔙</span>
|
|
<span class="fb-name">..</span>
|
|
</div>`;
|
|
}
|
|
|
|
// Unterordner: Einfachklick = auswaehlen, Doppelklick = navigieren
|
|
for (const f of (data.folders || [])) {
|
|
const meta = f.video_count > 0 ? `${f.video_count} Videos` : "";
|
|
html += `<div class="import-browser-folder" onclick="importFolderClick('${escapeHtml(f.path)}', this)">
|
|
<span class="fb-icon">📁</span>
|
|
<span class="fb-name">${escapeHtml(f.name)}</span>
|
|
<span class="fb-meta">${meta}</span>
|
|
</div>`;
|
|
}
|
|
|
|
if (!data.folders?.length && !data.video_count) {
|
|
html += '<div class="loading-msg" style="padding:0.8rem">Leerer Ordner</div>';
|
|
}
|
|
|
|
browser.innerHTML = html;
|
|
|
|
// Pfad-Input aktualisieren und Video-Info anzeigen
|
|
updateImportFolderInfo(data);
|
|
})
|
|
.catch(e => {
|
|
browser.innerHTML = `<div class="loading-msg" style="padding:0.8rem">Fehler: ${e}</div>`;
|
|
});
|
|
}
|
|
|
|
// Klick-Handler: Einfachklick = auswaehlen, Doppelklick = navigieren
|
|
let _importClickTimer = null;
|
|
function importFolderClick(path, el) {
|
|
if (_importClickTimer) {
|
|
// Zweiter Klick innerhalb 300ms -> Doppelklick -> navigieren
|
|
clearTimeout(_importClickTimer);
|
|
_importClickTimer = null;
|
|
importBrowse(path);
|
|
} else {
|
|
// Erster Klick -> kurz warten ob Doppelklick kommt
|
|
_importClickTimer = setTimeout(() => {
|
|
_importClickTimer = null;
|
|
importSelectFolder(path, el);
|
|
}, 250);
|
|
}
|
|
}
|
|
|
|
function importSelectFolder(path, el) {
|
|
// Vorherige Auswahl entfernen
|
|
document.querySelectorAll(".import-browser-folder.selected").forEach(
|
|
f => f.classList.remove("selected")
|
|
);
|
|
el.classList.add("selected");
|
|
document.getElementById("import-source").value = path;
|
|
|
|
// Video-Info fuer den ausgewaehlten Ordner laden
|
|
fetch("/api/library/browse-fs?path=" + encodeURIComponent(path))
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.error) updateImportFolderInfo(data);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function updateImportFolderInfo(data) {
|
|
const info = document.getElementById("import-folder-info");
|
|
const btn = document.getElementById("btn-analyze-import");
|
|
const totalVids = (data.video_count || 0);
|
|
// Auch Unterordner zaehlen
|
|
let subVids = 0;
|
|
for (const f of (data.folders || [])) subVids += (f.video_count || 0);
|
|
const allVids = totalVids + subVids;
|
|
|
|
if (allVids > 0) {
|
|
info.textContent = `${allVids} Videos gefunden`;
|
|
btn.disabled = false;
|
|
} else {
|
|
info.textContent = "Keine Videos im Ordner";
|
|
btn.disabled = true;
|
|
}
|
|
}
|
|
|
|
function createImportJob() {
|
|
const source = document.getElementById("import-source").value.trim();
|
|
const target = document.getElementById("import-target").value;
|
|
const mode = document.getElementById("import-mode").value;
|
|
if (!source || !target) { alert("Quellordner und Ziel erforderlich"); return; }
|
|
|
|
const btn = document.getElementById("btn-analyze-import");
|
|
btn.textContent = "Analysiere...";
|
|
btn.disabled = true;
|
|
|
|
fetch("/api/library/import", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({source_path: source, target_library_id: parseInt(target), mode}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { alert("Fehler: " + data.error); btn.textContent = "Analysieren"; btn.disabled = false; return; }
|
|
currentImportJobId = data.job_id;
|
|
return fetch(`/api/library/import/${data.job_id}/analyze`, {method: "POST"});
|
|
})
|
|
.then(r => r ? r.json() : null)
|
|
.then(data => {
|
|
btn.textContent = "Analysieren";
|
|
btn.disabled = false;
|
|
if (!data) return;
|
|
if (data.error) { alert("Analyse-Fehler: " + data.error); return; }
|
|
document.getElementById("import-setup").style.display = "none";
|
|
document.getElementById("import-preview").style.display = "";
|
|
renderImportItems(data);
|
|
})
|
|
.catch(e => { btn.textContent = "Analysieren"; btn.disabled = false; alert("Fehler: " + e); });
|
|
}
|
|
|
|
function renderImportItems(data) {
|
|
const items = data.items || [];
|
|
const list = document.getElementById("import-items-list");
|
|
|
|
const matched = items.filter(i => i.status === "matched").length;
|
|
const conflicts = items.filter(i => i.status === "conflict").length;
|
|
const pending = items.filter(i => i.status === "pending").length;
|
|
document.getElementById("import-info").textContent =
|
|
`${items.length} Dateien: ${matched} erkannt, ${conflicts} Konflikte, ${pending} offen`;
|
|
|
|
// Start-Button nur wenn keine ungeloesten Konflikte UND keine pending Items
|
|
const hasUnresolved = items.some(i =>
|
|
(i.status === "conflict" && !i.user_action) || i.status === "pending"
|
|
);
|
|
const btn = document.getElementById("btn-start-import");
|
|
btn.disabled = hasUnresolved;
|
|
btn.title = hasUnresolved
|
|
? `${pending} Dateien muessen erst zugeordnet werden`
|
|
: "Import starten";
|
|
|
|
if (!items.length) {
|
|
list.innerHTML = '<div class="loading-msg">Keine Dateien gefunden</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table class="data-table"><thead><tr>';
|
|
html += '<th>Quelldatei</th><th>Serie</th><th>S/E</th><th>Titel</th><th>Ziel</th><th>Status</th><th>Aktion</th>';
|
|
html += '</tr></thead><tbody>';
|
|
|
|
for (const item of items) {
|
|
const statusClass = item.status === "conflict" ? "status-badge warn"
|
|
: item.status === "matched" ? "status-badge ok"
|
|
: item.status === "done" ? "status-badge ok"
|
|
: item.status === "pending" ? "status-badge error"
|
|
: "status-badge";
|
|
const statusText = item.status === "conflict" ? "Konflikt"
|
|
: item.status === "matched" ? "OK"
|
|
: item.status === "done" ? "Fertig"
|
|
: item.status === "skipped" ? "Uebersprungen"
|
|
: item.status === "pending" ? "Nicht erkannt"
|
|
: item.status;
|
|
|
|
const sourceName = item.source_file ? item.source_file.split("/").pop() : "-";
|
|
const se = (item.detected_season && item.detected_episode)
|
|
? `S${String(item.detected_season).padStart(2, "0")}E${String(item.detected_episode).padStart(2, "0")}`
|
|
: "-";
|
|
|
|
let actionHtml = "";
|
|
if (item.status === "conflict" && !item.user_action) {
|
|
actionHtml = `
|
|
<button class="btn-small btn-primary" onclick="resolveImportConflict(${item.id}, 'overwrite')">Ueberschr.</button>
|
|
<button class="btn-small btn-secondary" onclick="resolveImportConflict(${item.id}, 'skip')">Skip</button>
|
|
<button class="btn-small btn-secondary" onclick="resolveImportConflict(${item.id}, 'rename')">Umbenennen</button>
|
|
`;
|
|
} else if (item.status === "pending") {
|
|
actionHtml = `
|
|
<button class="btn-small btn-primary" onclick="openImportAssignModal(${item.id}, '${escapeAttr(sourceName)}')">Zuordnen</button>
|
|
<button class="btn-small btn-secondary" onclick="skipImportItem(${item.id})">Skip</button>
|
|
`;
|
|
} else if (item.user_action) {
|
|
actionHtml = `<span class="tag">${item.user_action}</span>`;
|
|
}
|
|
|
|
const rowClass = item.status === "conflict" ? "row-conflict"
|
|
: item.status === "pending" ? "row-pending" : "";
|
|
html += `<tr class="${rowClass}">
|
|
<td class="td-name" title="${escapeHtml(item.source_file || '')}">${escapeHtml(sourceName)}</td>
|
|
<td>${escapeHtml(item.tvdb_series_name || item.detected_series || "-")}</td>
|
|
<td>${se}</td>
|
|
<td>${escapeHtml(item.tvdb_episode_title || "-")}</td>
|
|
<td class="td-name" title="${escapeHtml((item.target_path || '') + '/' + (item.target_filename || ''))}">${escapeHtml(item.target_filename || "-")}</td>
|
|
<td><span class="${statusClass}">${statusText}</span>
|
|
${item.conflict_reason ? `<div class="text-muted" style="font-size:0.7rem">${escapeHtml(item.conflict_reason)}</div>` : ""}
|
|
</td>
|
|
<td class="import-actions-cell">${actionHtml}</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
list.innerHTML = html;
|
|
}
|
|
|
|
function resolveImportConflict(itemId, action) {
|
|
fetch(`/api/library/import/items/${itemId}/resolve`, {
|
|
method: "PUT",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({action}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(() => refreshImportPreview())
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
// === Import-Zuordnungs-Modal ===
|
|
|
|
let _assignItemId = null;
|
|
let _assignTvdbId = null;
|
|
let _assignSeriesName = "";
|
|
let _assignSearchTimer = null;
|
|
|
|
function openImportAssignModal(itemId, filename) {
|
|
_assignItemId = itemId;
|
|
_assignTvdbId = null;
|
|
_assignSeriesName = "";
|
|
|
|
const modal = document.getElementById("import-assign-modal");
|
|
modal.style.display = "flex";
|
|
document.getElementById("import-assign-filename").textContent = filename;
|
|
document.getElementById("import-assign-search").value = "";
|
|
document.getElementById("import-assign-results").innerHTML = "";
|
|
document.getElementById("import-assign-selected").style.display = "none";
|
|
document.getElementById("import-assign-season").value = "";
|
|
document.getElementById("import-assign-episode").value = "";
|
|
document.getElementById("import-assign-search").focus();
|
|
}
|
|
|
|
function closeImportAssignModal() {
|
|
document.getElementById("import-assign-modal").style.display = "none";
|
|
_assignItemId = null;
|
|
}
|
|
|
|
function debounceAssignSearch() {
|
|
if (_assignSearchTimer) clearTimeout(_assignSearchTimer);
|
|
_assignSearchTimer = setTimeout(searchAssignTvdb, 500);
|
|
}
|
|
|
|
function searchAssignTvdb() {
|
|
const query = document.getElementById("import-assign-search").value.trim();
|
|
if (!query) return;
|
|
|
|
const results = document.getElementById("import-assign-results");
|
|
results.innerHTML = '<div class="loading-msg">Suche...</div>';
|
|
|
|
fetch(`/api/tvdb/search?q=${encodeURIComponent(query)}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { results.innerHTML = `<div class="loading-msg">${escapeHtml(data.error)}</div>`; return; }
|
|
if (!data.results || !data.results.length) { results.innerHTML = '<div class="loading-msg">Keine Ergebnisse</div>'; return; }
|
|
results.innerHTML = data.results.slice(0, 8).map(r => `
|
|
<div class="tvdb-result" onclick="selectAssignSeries(${r.tvdb_id}, '${escapeAttr(r.name)}')">
|
|
${r.poster ? `<img src="${r.poster}" alt="" class="tvdb-thumb">` : ""}
|
|
<div>
|
|
<strong>${escapeHtml(r.name)}</strong>
|
|
<span class="text-muted">${r.year || ""}</span>
|
|
<p class="tvdb-overview">${escapeHtml((r.overview || "").substring(0, 120))}</p>
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
})
|
|
.catch(e => { results.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
}
|
|
|
|
function selectAssignSeries(tvdbId, name) {
|
|
_assignTvdbId = tvdbId;
|
|
_assignSeriesName = name;
|
|
document.getElementById("import-assign-results").innerHTML = "";
|
|
document.getElementById("import-assign-selected").style.display = "";
|
|
document.getElementById("import-assign-selected-name").textContent = name;
|
|
document.getElementById("import-assign-search").value = "";
|
|
}
|
|
|
|
function submitImportAssign() {
|
|
if (!_assignItemId) return;
|
|
|
|
const season = parseInt(document.getElementById("import-assign-season").value);
|
|
const episode = parseInt(document.getElementById("import-assign-episode").value);
|
|
const manualName = document.getElementById("import-assign-search").value.trim();
|
|
const seriesName = _assignSeriesName || manualName;
|
|
|
|
if (!seriesName) { alert("Serie auswaehlen oder Namen eingeben"); return; }
|
|
if (isNaN(season) || isNaN(episode)) { alert("Staffel und Episode eingeben"); return; }
|
|
|
|
const btn = document.querySelector("#import-assign-modal .btn-primary");
|
|
btn.disabled = true;
|
|
btn.textContent = "Zuordne...";
|
|
|
|
fetch(`/api/library/import/items/${_assignItemId}/reassign`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
series_name: seriesName,
|
|
season: season,
|
|
episode: episode,
|
|
tvdb_id: _assignTvdbId || null,
|
|
}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.disabled = false;
|
|
btn.textContent = "Zuordnen";
|
|
if (data.error) { alert("Fehler: " + data.error); return; }
|
|
closeImportAssignModal();
|
|
refreshImportPreview();
|
|
})
|
|
.catch(e => { btn.disabled = false; btn.textContent = "Zuordnen"; alert("Fehler: " + e); });
|
|
}
|
|
|
|
function skipImportItem(itemId) {
|
|
fetch(`/api/library/import/items/${itemId}/skip`, {method: "POST"})
|
|
.then(r => r.json())
|
|
.then(() => refreshImportPreview())
|
|
.catch(e => alert("Fehler: " + e));
|
|
}
|
|
|
|
function refreshImportPreview() {
|
|
if (!currentImportJobId) return;
|
|
fetch(`/api/library/import/${currentImportJobId}`)
|
|
.then(r => r.json())
|
|
.then(data => renderImportItems(data))
|
|
.catch(() => {});
|
|
}
|
|
|
|
let importPollingId = null;
|
|
|
|
function executeImport() {
|
|
if (!currentImportJobId || !confirm("Import jetzt starten?")) return;
|
|
|
|
document.getElementById("import-preview").style.display = "none";
|
|
document.getElementById("import-progress").style.display = "";
|
|
document.getElementById("import-status-text").textContent = "Importiere...";
|
|
document.getElementById("import-bar").style.width = "0%";
|
|
|
|
// Starte Import (non-blocking - Server antwortet sofort)
|
|
fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"});
|
|
|
|
// Polling fuer Fortschritt starten
|
|
startImportPolling();
|
|
}
|
|
|
|
function startImportPolling() {
|
|
if (importPollingId) clearInterval(importPollingId);
|
|
|
|
importPollingId = setInterval(async () => {
|
|
try {
|
|
const r = await fetch(`/api/library/import/${currentImportJobId}`);
|
|
const data = await r.json();
|
|
|
|
if (data.error) {
|
|
stopImportPolling();
|
|
document.getElementById("import-status-text").textContent = "Fehler: " + data.error;
|
|
return;
|
|
}
|
|
|
|
const job = data.job;
|
|
if (!job) return;
|
|
|
|
const total = job.total_files || 1;
|
|
const done = job.processed_files || 0;
|
|
|
|
// Byte-Fortschritt der aktuellen Datei
|
|
const curFile = job.current_file_name || "";
|
|
const curBytes = job.current_file_bytes || 0;
|
|
const curTotal = job.current_file_total || 0;
|
|
|
|
// Prozent: fertige Dateien + anteilig aktuelle Datei
|
|
let pct = (done / total) * 100;
|
|
if (curTotal > 0 && done < total) {
|
|
pct += (curBytes / curTotal) * (100 / total);
|
|
}
|
|
pct = Math.min(Math.round(pct), 100);
|
|
|
|
document.getElementById("import-bar").style.width = pct + "%";
|
|
|
|
// Status-Text mit Byte-Fortschritt
|
|
let statusText = `Importiere: ${done} / ${total} Dateien`;
|
|
if (curFile && curTotal > 0 && done < total) {
|
|
const curPct = Math.round((curBytes / curTotal) * 100);
|
|
statusText += ` - ${curFile.substring(0, 40)}... (${formatSize(curBytes)} / ${formatSize(curTotal)}, ${curPct}%)`;
|
|
} else {
|
|
statusText += ` (${pct}%)`;
|
|
}
|
|
document.getElementById("import-status-text").textContent = statusText;
|
|
|
|
// Fertig?
|
|
if (job.status === "done" || job.status === "error") {
|
|
stopImportPolling();
|
|
document.getElementById("import-bar").style.width = "100%";
|
|
|
|
// Zaehle Ergebnisse
|
|
const items = data.items || [];
|
|
const imported = items.filter(i => i.status === "done").length;
|
|
const errors = items.filter(i => i.status === "error").length;
|
|
const skipped = items.filter(i => i.status === "skipped").length;
|
|
|
|
document.getElementById("import-status-text").textContent =
|
|
`Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
|
|
|
|
// Nur Ziel-Pfad scannen und neu laden (statt alles)
|
|
const targetPathId = job.target_library_id;
|
|
if (targetPathId && imported > 0) {
|
|
// Gezielten Scan starten
|
|
fetch(`/api/library/scan/${targetPathId}`, {method: "POST"})
|
|
.then(() => {
|
|
// Warte kurz, dann nur diese Sektion neu laden
|
|
setTimeout(() => {
|
|
loadSectionData(targetPathId);
|
|
loadStats();
|
|
}, 2000);
|
|
})
|
|
.catch(() => {
|
|
// Fallback: Alles neu laden
|
|
reloadAllSections();
|
|
loadStats();
|
|
});
|
|
} else {
|
|
// Kein Import oder unbekannter Pfad: Alles neu laden
|
|
reloadAllSections();
|
|
loadStats();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Import-Polling Fehler:", e);
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
function stopImportPolling() {
|
|
if (importPollingId) {
|
|
clearInterval(importPollingId);
|
|
importPollingId = null;
|
|
}
|
|
}
|
|
|
|
// === Hilfsfunktionen ===
|
|
|
|
function formatSize(bytes) {
|
|
if (!bytes) return "0 B";
|
|
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
let i = 0;
|
|
let size = bytes;
|
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
|
return size.toFixed(i > 1 ? 1 : 0) + " " + units[i];
|
|
}
|
|
|
|
function formatDuration(sec) {
|
|
if (!sec || sec <= 0) return "-";
|
|
const d = Math.floor(sec / 86400);
|
|
const h = Math.floor((sec % 86400) / 3600);
|
|
const m = Math.floor((sec % 3600) / 60);
|
|
const parts = [];
|
|
if (d) parts.push(d + "d");
|
|
if (h) parts.push(h + "h");
|
|
if (m || !parts.length) parts.push(m + "m");
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function resolutionLabel(w, h) {
|
|
if (w >= 3840) return "4K";
|
|
if (w >= 1920) return "1080p";
|
|
if (w >= 1280) return "720p";
|
|
if (w >= 720) return "576p";
|
|
return w + "x" + h;
|
|
}
|
|
|
|
function channelLayout(ch) {
|
|
const layouts = {1: "Mono", 2: "2.0", 3: "2.1", 6: "5.1", 8: "7.1"};
|
|
return layouts[ch] || ch + "ch";
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return "";
|
|
return str.replace(/&/g, "&").replace(/</g, "<")
|
|
.replace(/>/g, ">").replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
// JS-String-Literal sicher fuer HTML-onclick-Attribute erzeugen
|
|
// JSON.stringify erzeugt "...", die " muessen fuer HTML-Attribute escaped werden
|
|
return JSON.stringify(str || "").replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
}
|
|
|
|
function cleanSearchTitle(title) {
|
|
// Fuehrende Sortier-Nummern und Aufloesung-Suffixe entfernen
|
|
return title
|
|
.replace(/^\d{1,2}\s+/, '')
|
|
.replace(/\s*(720p|1080p|2160p|4k|bluray|bdrip|webrip|web-dl|hdtv|x264|x265|hevc|aac|dts|remux)\s*/gi, ' ')
|
|
.trim();
|
|
}
|
|
|
|
// === Video-Player ===
|
|
|
|
let _playerVideoId = null;
|
|
|
|
function playVideo(videoId, title) {
|
|
const modal = document.getElementById("player-modal");
|
|
const video = document.getElementById("player-video");
|
|
document.getElementById("player-title").textContent = title || "Video";
|
|
_playerVideoId = videoId;
|
|
|
|
// Alte Quelle stoppen
|
|
video.pause();
|
|
video.removeAttribute("src");
|
|
video.load();
|
|
|
|
// Neue Quelle setzen (ffmpeg-Transcoding-Stream)
|
|
video.src = `/api/library/videos/${videoId}/stream`;
|
|
modal.style.display = "flex";
|
|
|
|
video.play().catch(() => {
|
|
// Autoplay blockiert - User muss manuell starten
|
|
});
|
|
}
|
|
|
|
function closePlayer() {
|
|
const video = document.getElementById("player-video");
|
|
video.pause();
|
|
video.removeAttribute("src");
|
|
video.load();
|
|
_playerVideoId = null;
|
|
document.getElementById("player-modal").style.display = "none";
|
|
}
|
|
|
|
// ESC schliesst den Player
|
|
document.addEventListener("keydown", function(e) {
|
|
if (e.key === "Escape") {
|
|
const player = document.getElementById("player-modal");
|
|
if (player && player.style.display === "flex") {
|
|
closePlayer();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
});
|
|
|
|
// === Video loeschen ===
|
|
|
|
function deleteVideo(videoId, title, context) {
|
|
if (!confirm(`"${title}" wirklich loeschen?\n\nDatei wird unwiderruflich entfernt!`)) return;
|
|
|
|
fetch(`/api/library/videos/${videoId}?delete_file=1`, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) { showToast("Fehler: " + data.error, "error"); return; }
|
|
showToast("Video geloescht", "success");
|
|
|
|
// Ansicht aktualisieren
|
|
if (context === "series" && currentSeriesId) {
|
|
openSeriesDetail(currentSeriesId);
|
|
} else if (context === "movie" && currentMovieId) {
|
|
openMovieDetail(currentMovieId);
|
|
} else {
|
|
reloadAllSections();
|
|
}
|
|
loadStats();
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|