- PWA Cookie-Fix: SameSite/Secure je nach Protokoll (HTTP=Lax, HTTPS=None+Secure) - Samsung Fernbedienung: Media-Key-Registrierung, Return/Back navigiert zurueck - Post-Play Navigation: Countdown auf naechster Episode nach Wiedergabe-Ende - Gelbe Staffel-Tabs: Gold-Farbe wenn alle Episoden gesehen - Episoden Card-Grid: Plex-Style Thumbnail-Grid mit Detail-Panel bei Focus - Weiche Uebergaenge: Fade-In/Out Animationen fuer Player und Seitenwechsel - Codec-Badge: AV1/HEVC Badge in Videobibliothek bei komplett konvertierten Serien - Separate Import-Fortschrittsbalken: Pro Import-Job eigener Balken - Android APK signiert (v2+v3 Scheme) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3208 lines
130 KiB
JavaScript
3208 lines
130 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 () {
|
|
// Gespeicherten UI-State wiederherstellen
|
|
_restoreUIState();
|
|
loadStats();
|
|
loadFilterPresets();
|
|
loadLibraryPaths();
|
|
});
|
|
|
|
// === UI-State in localStorage ===
|
|
|
|
function _saveUIState() {
|
|
try {
|
|
localStorage.setItem("vk_activePathId", JSON.stringify(activePathId));
|
|
// Aktuelle Filter-Werte speichern
|
|
const filterState = {};
|
|
const fields = [
|
|
"filter-search", "filter-codec", "filter-container",
|
|
"filter-audio-lang", "filter-audio-ch", "filter-resolution",
|
|
"filter-sort", "filter-order"
|
|
];
|
|
for (const f of fields) {
|
|
const el = document.getElementById(f);
|
|
if (el) filterState[f] = el.value;
|
|
}
|
|
const cb10bit = document.getElementById("filter-10bit");
|
|
if (cb10bit) filterState["filter-10bit"] = cb10bit.checked;
|
|
const cbNotConv = document.getElementById("filter-not-converted");
|
|
if (cbNotConv) filterState["filter-not-converted"] = cbNotConv.checked;
|
|
localStorage.setItem("vk_filterState", JSON.stringify(filterState));
|
|
// Tab-States pro Pfad speichern
|
|
const tabStates = {};
|
|
for (const [pid, st] of Object.entries(sectionStates)) {
|
|
tabStates[pid] = st.tab;
|
|
}
|
|
localStorage.setItem("vk_tabStates", JSON.stringify(tabStates));
|
|
} catch (e) { /* localStorage nicht verfuegbar */ }
|
|
}
|
|
|
|
function _restoreUIState() {
|
|
try {
|
|
const stored = localStorage.getItem("vk_activePathId");
|
|
if (stored !== null) activePathId = JSON.parse(stored);
|
|
} catch (e) { /* ignorieren */ }
|
|
}
|
|
|
|
function _restoreFilterState() {
|
|
try {
|
|
const raw = localStorage.getItem("vk_filterState");
|
|
if (!raw) return;
|
|
const fs = JSON.parse(raw);
|
|
const fields = [
|
|
"filter-search", "filter-codec", "filter-container",
|
|
"filter-audio-lang", "filter-audio-ch", "filter-resolution",
|
|
"filter-sort", "filter-order"
|
|
];
|
|
for (const f of fields) {
|
|
const el = document.getElementById(f);
|
|
if (el && fs[f] !== undefined) el.value = fs[f];
|
|
}
|
|
const cb10bit = document.getElementById("filter-10bit");
|
|
if (cb10bit && fs["filter-10bit"] !== undefined) cb10bit.checked = fs["filter-10bit"];
|
|
const cbNotConv = document.getElementById("filter-not-converted");
|
|
if (cbNotConv && fs["filter-not-converted"] !== undefined) cbNotConv.checked = fs["filter-not-converted"];
|
|
} catch (e) { /* ignorieren */ }
|
|
}
|
|
|
|
function _restoreTabStates() {
|
|
try {
|
|
const raw = localStorage.getItem("vk_tabStates");
|
|
if (!raw) return;
|
|
const ts = JSON.parse(raw);
|
|
for (const [pid, tab] of Object.entries(ts)) {
|
|
if (sectionStates[pid]) {
|
|
sectionStates[pid].tab = tab;
|
|
}
|
|
}
|
|
} catch (e) { /* ignorieren */ }
|
|
}
|
|
|
|
// === 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;
|
|
_saveUIState();
|
|
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;
|
|
|
|
// Gespeicherte Tab-States wiederherstellen
|
|
_restoreTabStates();
|
|
|
|
// 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;
|
|
_saveUIState();
|
|
// 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 redundant = s.redundant_files > 0
|
|
? `<span class="status-badge info" title="Mehrere Dateien fuer gleiche Episode">${s.redundant_files} redundant</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>`;
|
|
const codecBadge = s.codec_badge
|
|
? `<span class="series-codec-badge">${escapeHtml(s.codec_badge)}</span>` : "";
|
|
|
|
html += `<div class="series-card" onclick="openSeriesDetail(${s.id})">
|
|
<div class="series-poster-wrap">
|
|
${poster}
|
|
${codecBadge}
|
|
</div>
|
|
<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}
|
|
${redundant}
|
|
${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}, '${escapeAttr(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>`;
|
|
if (series.redundant_files > 0) html += `<span class="status-badge info" title="Mehrere Dateien fuer gleiche Episode (z.B. .mkv und .webm)">${series.redundant_files} redundant</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>Typ</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));
|
|
|
|
// Redundante Dateien erkennen: gleiche Episode-Nummer mehrfach vorhanden
|
|
// Die "beste" Datei behalten (kleinere Datei bei gleichem Codec, neueres Format bevorzugt)
|
|
const epGroups = {};
|
|
for (const ep of allEps) {
|
|
if (ep._type !== "local" || !ep.episode_number) continue;
|
|
const key = `${ep.season_number || 0}-${ep.episode_number}`;
|
|
if (!epGroups[key]) epGroups[key] = [];
|
|
epGroups[key].push(ep);
|
|
}
|
|
const redundantIds = new Set();
|
|
const codecRank = {av1: 4, hevc: 3, h265: 3, h264: 2, x264: 2, mpeg4: 1, mpeg2video: 0};
|
|
for (const key of Object.keys(epGroups)) {
|
|
const group = epGroups[key];
|
|
if (group.length <= 1) continue;
|
|
// Sortiere: neuerer Codec besser, bei gleichem Codec kleinere Datei besser
|
|
group.sort((a, b) => {
|
|
const ra = codecRank[(a.video_codec || "").toLowerCase()] || 0;
|
|
const rb = codecRank[(b.video_codec || "").toLowerCase()] || 0;
|
|
if (ra !== rb) return rb - ra;
|
|
return (a.file_size || 0) - (b.file_size || 0);
|
|
});
|
|
// Alle ausser dem ersten sind redundant
|
|
for (let i = 1; i < group.length; i++) {
|
|
redundantIds.add(group[i].id);
|
|
}
|
|
}
|
|
|
|
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="4" class="text-muted">Nicht vorhanden</td>
|
|
<td><span class="status-badge error">FEHLT</span></td>
|
|
</tr>`;
|
|
} else {
|
|
const isRedundant = redundantIds.has(ep.id);
|
|
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";
|
|
const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-";
|
|
const redundantBadge = isRedundant ? ' <span class="status-badge warn" title="Duplikat - kann geloescht werden">REDUNDANT</span>' : '';
|
|
html += `<tr data-video-id="${ep.id}" class="${isRedundant ? 'row-redundant' : ''}">
|
|
<td>${ep.episode_number || "-"}</td>
|
|
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}${redundantBadge}</td>
|
|
<td>${res}</td>
|
|
<td><span class="tag codec">${ep.video_codec || "-"}</span></td>
|
|
<td><span class="tag">${fileExt}</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) showToast("Fehler: " + data.error, "error");
|
|
else { showToast("TVDB aktualisiert: " + (data.name || ""), "success"); openSeriesDetail(currentSeriesId); }
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
async function tvdbUnlink() {
|
|
if (!currentSeriesId) return;
|
|
if (!await showConfirm("TVDB-Zuordnung wirklich loesen?")) return;
|
|
fetch(`/api/library/series/${currentSeriesId}/tvdb`, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) showToast("Fehler: " + data.error, "error");
|
|
else { closeSeriesModal(); reloadAllSections(); }
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
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) showToast("Fehler: " + data.error, "error");
|
|
else showToast(`${data.downloaded || 0} Dateien heruntergeladen, ${data.errors || 0} Fehler`, "success");
|
|
})
|
|
.catch(e => { btn.textContent = "Metadaten laden"; btn.disabled = false; showToast("Fehler: " + e, "error"); });
|
|
}
|
|
|
|
async function deleteSeries(withFiles) {
|
|
if (!currentSeriesId) return;
|
|
if (withFiles) {
|
|
if (!await showConfirm("Alle Dateien und Ordner werden <strong>UNWIDERRUFLICH</strong> geloescht!", {title: "Serie komplett loeschen", icon: "danger", okText: "Endgueltig loeschen", danger: true})) return;
|
|
} else {
|
|
if (!await showConfirm("Serie aus der Datenbank loeschen?", {detail: "Dateien bleiben erhalten", okText: "Aus DB loeschen", danger: true})) 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) { showToast("Fehler: " + data.error, "error"); return; }
|
|
let msg = "Serie aus DB geloescht.";
|
|
if (data.deleted_folder) msg += " Ordner geloescht.";
|
|
showToast(msg, "success");
|
|
if (data.folder_error) showToast("Ordner-Fehler: " + data.folder_error, "error");
|
|
closeSeriesModal();
|
|
reloadAllSections();
|
|
loadStats();
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
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 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"));
|
|
}
|
|
|
|
// === 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>Typ</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";
|
|
const fileExt = (v.file_name || "").split(".").pop().toUpperCase() || "-";
|
|
html += `<tr data-video-id="${v.id}">
|
|
<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><span class="tag">${fileExt}</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;
|
|
}
|
|
|
|
async function movieTvdbUnlink() {
|
|
if (!currentMovieId) return;
|
|
if (!await showConfirm("TVDB-Zuordnung wirklich loesen?", {title: "TVDB trennen", okText: "Trennen"})) return;
|
|
fetch(`/api/library/movies/${currentMovieId}/tvdb`, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) showToast("Fehler: " + data.error, "error");
|
|
else { closeMovieModal(); reloadAllSections(); }
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
async function deleteMovie(withFiles) {
|
|
if (!currentMovieId) return;
|
|
if (withFiles) {
|
|
if (!await showConfirm("Film komplett loeschen?<br><br>Alle Dateien werden <strong>UNWIDERRUFLICH</strong> geloescht!", {title: "Film loeschen", okText: "Endgueltig loeschen", icon: "danger", danger: true})) return;
|
|
if (!await showConfirm("Wirklich sicher?", {title: "Letzte Warnung", okText: "Ja, loeschen", icon: "danger", danger: true})) return;
|
|
} else {
|
|
if (!await showConfirm("Film aus der Datenbank loeschen?<br>(Dateien bleiben erhalten)", {title: "Film entfernen", okText: "Aus DB loeschen"})) 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) { showToast("Fehler: " + data.error, "error"); return; }
|
|
showToast("Film aus DB geloescht." + (data.deleted_folder ? " Ordner geloescht." : ""), "success");
|
|
closeMovieModal();
|
|
reloadAllSections();
|
|
loadStats();
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
// === 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 = "";
|
|
_saveUIState();
|
|
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);
|
|
}
|
|
|
|
// Gespeicherten Filter-State wiederherstellen (hat Vorrang)
|
|
_restoreFilterState();
|
|
|
|
// Standard-Ansicht nur beim ersten Besuch anwenden
|
|
if (!localStorage.getItem("vk_filterState") && 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"
|
|
_updateDeletePresetBtn();
|
|
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;
|
|
_updateDeletePresetBtn();
|
|
_saveUIState();
|
|
|
|
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 = await showPrompt("Name fuer diesen Filter:", {title: "Filter speichern", placeholder: "z.B. Nur 4K", okText: "Speichern"});
|
|
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");
|
|
}
|
|
}
|
|
|
|
// Aktuelles Custom-Preset loeschen
|
|
async function deleteCurrentPreset() {
|
|
const select = document.getElementById("filter-preset");
|
|
const presetId = select.value;
|
|
// Nur Custom-Presets loeschen (nicht die eingebauten)
|
|
const builtIn = ["", "not_converted", "old_formats", "missing_episodes"];
|
|
if (builtIn.includes(presetId)) return;
|
|
|
|
if (!await showConfirm(`Filter "${escapeHtml(select.options[select.selectedIndex].text)}" wirklich loeschen?`, {title: "Filter loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
|
|
|
try {
|
|
const resp = await fetch(`/api/library/filter-presets/${presetId}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (resp.ok) {
|
|
showToast("Filter geloescht", "success");
|
|
select.value = "";
|
|
applyPreset("");
|
|
loadFilterPresets();
|
|
} else {
|
|
showToast("Fehler beim Loeschen", "error");
|
|
}
|
|
} catch (e) {
|
|
showToast("Fehler: " + e, "error");
|
|
}
|
|
}
|
|
|
|
// Loeschen-Button je nach Preset ein-/ausblenden
|
|
function _updateDeletePresetBtn() {
|
|
const btn = document.getElementById("btn-delete-preset");
|
|
if (!btn) return;
|
|
const presetId = document.getElementById("filter-preset").value;
|
|
const builtIn = ["", "not_converted", "old_formats", "missing_episodes"];
|
|
btn.style.display = builtIn.includes(presetId) ? "none" : "";
|
|
}
|
|
|
|
// === Scan ===
|
|
|
|
function startScan() {
|
|
fetch("/api/library/scan", {method: "POST"})
|
|
.catch(e => console.error("Scan-Fehler:", e));
|
|
// Progress-Updates kommen via WebSocket -> globaler Progress-Balken in base.html
|
|
}
|
|
|
|
function scanSinglePath(pathId) {
|
|
fetch(`/api/library/scan/${pathId}`, {method: "POST"})
|
|
.catch(e => console.error("Scan-Fehler:", e));
|
|
// Progress-Updates kommen via WebSocket -> globaler Progress-Balken in base.html
|
|
}
|
|
|
|
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
|
|
|
|
async function startAutoMatch() {
|
|
if (!await showConfirm("TVDB-Vorschlaege fuer alle nicht-zugeordneten Serien und Filme sammeln?", {title: "Auto-Match starten", detail: "Das kann einige Minuten dauern. Du kannst danach jeden Vorschlag pruefen und bestaetigen.", okText: "Starten", icon: "info"})) return;
|
|
|
|
_gpShow("automatch", "Auto-Match", "Suche TVDB-Vorschlaege...", 0);
|
|
|
|
fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
_gpShow("automatch", "Auto-Match", "Fehler: " + data.error, 0);
|
|
_gpHideDelayed("automatch");
|
|
return;
|
|
}
|
|
pollAutoMatchStatus();
|
|
})
|
|
.catch(e => {
|
|
_gpShow("automatch", "Auto-Match", "Fehler: " + e, 0);
|
|
_gpHideDelayed("automatch");
|
|
});
|
|
}
|
|
|
|
function pollAutoMatchStatus() {
|
|
const interval = setInterval(() => {
|
|
fetch("/api/library/tvdb-auto-match-status")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.phase === "done") {
|
|
clearInterval(interval);
|
|
const suggestions = data.suggestions || [];
|
|
const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0);
|
|
const noResults = suggestions.length - withSuggestions.length;
|
|
_gpShow("automatch", "Auto-Match",
|
|
withSuggestions.length + " Vorschlaege, " + noResults + " ohne Ergebnis", 100);
|
|
_gpHideDelayed("automatch");
|
|
|
|
if (withSuggestions.length > 0) {
|
|
openTvdbReviewModal(withSuggestions);
|
|
}
|
|
} else if (data.phase === "error") {
|
|
clearInterval(interval);
|
|
_gpShow("automatch", "Auto-Match", "Fehler", 0);
|
|
_gpHideDelayed("automatch");
|
|
} else if (!data.active && data.phase !== "done") {
|
|
clearInterval(interval);
|
|
_gpHide("automatch");
|
|
} else {
|
|
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
|
|
const phase = data.phase === "series" ? "Serien" : "Filme";
|
|
_gpShow("automatch", "Auto-Match",
|
|
phase + ": " + (data.current || "") + " (" + data.done + "/" + data.total + ")", pct);
|
|
}
|
|
})
|
|
.catch(() => clearInterval(interval));
|
|
}, 5000);
|
|
}
|
|
|
|
// === 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) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
if (el) el.classList.remove("review-item-loading");
|
|
return;
|
|
}
|
|
item._confirmed = true;
|
|
item._confirmedName = data.name || name;
|
|
renderTvdbReviewList();
|
|
})
|
|
.catch(e => {
|
|
showToast("Fehler: " + e, "error");
|
|
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 ===
|
|
|
|
async function convertVideo(videoId) {
|
|
if (event) event.stopPropagation();
|
|
if (!await showConfirm("Video zur Konvertierung senden?", {title: "Konvertieren", okText: "Starten", icon: "info"})) 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) showToast("Fehler: " + data.error, "error");
|
|
else showToast(data.message || "Job erstellt", "success");
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
// === 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) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
return;
|
|
}
|
|
|
|
let msg = data.message || "Konvertierung gestartet";
|
|
if (data.already_done > 0) {
|
|
msg += ` (${data.already_done} bereits im Zielformat)`;
|
|
}
|
|
showToast(msg, "success");
|
|
closeConvertSeriesModal();
|
|
})
|
|
.catch(e => {
|
|
btn.textContent = "Konvertierung starten";
|
|
btn.disabled = false;
|
|
showToast("Fehler: " + e, "error");
|
|
});
|
|
}
|
|
|
|
// === Serien-Ordner aufraeumen ===
|
|
|
|
async function cleanupSeriesFolder() {
|
|
if (!currentSeriesId) return;
|
|
|
|
if (!await showConfirm("Alle Dateien im Serien-Ordner loeschen, die <strong>NICHT</strong> in der Bibliothek sind?", {
|
|
title: "Ordner aufraeumen",
|
|
detail: "Behalten werden:<br>- Alle Videos in der Bibliothek<br>- .metadata Ordner<br>- .nfo, .jpg, .png Dateien<br><br><span style='color:#e74c3c'>Dies kann nicht rueckgaengig gemacht werden!</span>",
|
|
okText: "Aufraeumen",
|
|
icon: "danger",
|
|
danger: true
|
|
})) 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) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
return;
|
|
}
|
|
let msg = `${data.deleted} Dateien geloescht.`;
|
|
if (data.errors > 0) msg += ` ${data.errors} Fehler.`;
|
|
showToast(msg, data.errors > 0 ? "warning" : "success");
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
// === 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) { showToast("Name und Pfad erforderlich", "error"); 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) showToast("Fehler: " + data.error, "error");
|
|
else {
|
|
document.getElementById("new-path-name").value = "";
|
|
document.getElementById("new-path-path").value = "";
|
|
showToast("Pfad hinzugefuegt", "success");
|
|
loadPathsList();
|
|
loadLibraryPaths();
|
|
}
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
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) showToast("Fehler: " + data.error, "error");
|
|
else { loadPathsList(); loadLibraryPaths(); }
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
async function deletePath(pathId) {
|
|
if (!await showConfirm("Pfad wirklich loeschen?<br>(Videos bleiben erhalten, aber werden aus DB entfernt)", {title: "Pfad loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
|
fetch(`/api/library/paths/${pathId}`, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) showToast("Fehler: " + data.error, "error");
|
|
else { showToast("Pfad geloescht", "success"); loadPathsList(); loadLibraryPaths(); }
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
// === 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);
|
|
}
|
|
|
|
async function deleteSelectedJunk() {
|
|
const checked = document.querySelectorAll(".clean-check:checked");
|
|
if (!checked.length) { showToast("Keine Dateien ausgewaehlt", "error"); return; }
|
|
if (!await showConfirm(`${checked.length} Dateien wirklich loeschen?`, {title: "Junk loeschen", okText: "Loeschen", icon: "danger", danger: true})) 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 => {
|
|
showToast(`${data.deleted || 0} geloescht, ${data.failed || 0} fehlgeschlagen`, data.failed > 0 ? "warning" : "success");
|
|
if (data.errors && data.errors.length) console.warn("Clean-Fehler:", data.errors);
|
|
scanForJunk();
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
function deleteEmptyDirs() {
|
|
fetch("/api/library/clean/empty-dirs", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => showToast(`${data.deleted_dirs || 0} leere Ordner geloescht`, "success"))
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
// === 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}" ${activePathId === p.id ? 'selected' : ''}>${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";
|
|
});
|
|
}
|
|
|
|
async function deleteImportJob(jobId, ev) {
|
|
if (ev) ev.stopPropagation();
|
|
if (!await showConfirm("Import-Job wirklich loeschen?", {title: "Job loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
|
fetch(`/api/library/import/${jobId}`, { method: "DELETE" })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
return;
|
|
}
|
|
showToast("Import-Job geloescht", "success");
|
|
loadExistingImportJobs();
|
|
})
|
|
.catch(() => showToast("Loeschen fehlgeschlagen", "error"));
|
|
}
|
|
|
|
async function deleteCurrentImportJob() {
|
|
if (!currentImportJobId) return;
|
|
if (!await showConfirm("Import-Job wirklich loeschen?", {title: "Job loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
|
fetch(`/api/library/import/${currentImportJobId}`, { method: "DELETE" })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
return;
|
|
}
|
|
showToast("Import-Job geloescht", "success");
|
|
resetImport();
|
|
})
|
|
.catch(() => showToast("Loeschen fehlgeschlagen", "error"));
|
|
}
|
|
|
|
function loadImportJob(jobId) {
|
|
currentImportJobId = jobId;
|
|
document.getElementById("import-setup").style.display = "none";
|
|
document.getElementById("import-existing").style.display = "none";
|
|
document.getElementById("import-series-assign").style.display = "none";
|
|
document.getElementById("import-preview").style.display = "none";
|
|
|
|
fetch(`/api/library/import/${jobId}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
resetImport();
|
|
return;
|
|
}
|
|
|
|
const job = data.job || {};
|
|
|
|
// Je nach Status richtigen Bereich anzeigen
|
|
if (job.status === 'importing') {
|
|
document.getElementById("import-progress").style.display = "";
|
|
startImportPolling();
|
|
} else if (job.status === 'pending_assignment') {
|
|
document.getElementById("import-series-assign").style.display = "";
|
|
loadPendingSeries();
|
|
} else {
|
|
document.getElementById("import-preview").style.display = "";
|
|
renderImportItems(data);
|
|
}
|
|
})
|
|
.catch(e => {
|
|
showToast("Fehler beim Laden: " + e, "error");
|
|
resetImport();
|
|
});
|
|
}
|
|
|
|
function closeImportModal() {
|
|
document.getElementById("import-modal").style.display = "none";
|
|
}
|
|
|
|
function resetImport() {
|
|
document.getElementById("import-setup").style.display = "";
|
|
document.getElementById("import-series-assign").style.display = "none";
|
|
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('${escapeAttr(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('${escapeAttr(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('${escapeAttr(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;
|
|
}
|
|
}
|
|
|
|
async 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) { showToast("Quellordner und Ziel erforderlich", "error"); return; }
|
|
|
|
const btn = document.getElementById("btn-analyze-import");
|
|
btn.textContent = "Analysiere...";
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
// Job erstellen
|
|
let resp = await fetch("/api/library/import", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({source_path: source, target_library_id: parseInt(target), mode}),
|
|
});
|
|
let data = await resp.json();
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
btn.textContent = "Analysieren";
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
currentImportJobId = data.job_id;
|
|
|
|
// Analyse starten (nur Dateinamen-Erkennung, KEIN TVDB)
|
|
resp = await fetch(`/api/library/import/${data.job_id}/analyze`, {method: "POST"});
|
|
data = await resp.json();
|
|
btn.textContent = "Analysieren";
|
|
btn.disabled = false;
|
|
|
|
if (data.error) {
|
|
showToast("Analyse-Fehler: " + data.error, "error");
|
|
return;
|
|
}
|
|
|
|
// Pruefen ob Serien zugeordnet werden muessen
|
|
const job = data.job || {};
|
|
if (job.status === "pending_assignment") {
|
|
// Phase 2: Serien-Zuordnung anzeigen
|
|
document.getElementById("import-setup").style.display = "none";
|
|
document.getElementById("import-series-assign").style.display = "";
|
|
document.getElementById("import-preview").style.display = "none";
|
|
await loadPendingSeries();
|
|
} else {
|
|
// Direkt zu Konflikt-Ansicht
|
|
document.getElementById("import-setup").style.display = "none";
|
|
document.getElementById("import-series-assign").style.display = "none";
|
|
document.getElementById("import-preview").style.display = "";
|
|
renderImportItems(data);
|
|
}
|
|
} catch (e) {
|
|
btn.textContent = "Analysieren";
|
|
btn.disabled = false;
|
|
showToast("Fehler: " + e, "error");
|
|
}
|
|
}
|
|
|
|
// === Serien-Zuordnung (Phase 2) ===
|
|
|
|
async function loadPendingSeries() {
|
|
if (!currentImportJobId) return;
|
|
try {
|
|
const resp = await fetch(`/api/library/import/${currentImportJobId}/pending-series`);
|
|
const data = await resp.json();
|
|
renderPendingSeries(data.series || []);
|
|
} catch (e) {
|
|
showToast("Fehler beim Laden: " + e, "error");
|
|
}
|
|
}
|
|
|
|
function renderPendingSeries(series) {
|
|
const container = document.getElementById("import-series-list");
|
|
if (!series.length) {
|
|
// Keine Serien mehr -> weiter zu Konflikt-Check
|
|
finishSeriesAssignment();
|
|
return;
|
|
}
|
|
|
|
let html = '<div style="margin-bottom:1rem;color:#aaa">';
|
|
html += 'Die folgenden Serien wurden erkannt. Bitte ordne sie der richtigen TVDB-Serie zu:';
|
|
html += '</div>';
|
|
|
|
for (const s of series) {
|
|
html += `<div class="import-series-row" style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem;margin-bottom:0.3rem;background:#1a1a2e;border-radius:4px">`;
|
|
html += `<span style="flex:1"><strong>${escapeHtml(s.detected_name)}</strong>`;
|
|
html += ` <span class="text-muted">(${s.count} Folgen, Staffel ${s.seasons})</span></span>`;
|
|
html += `<button class="btn-small btn-primary import-assign-btn" data-series="${escapeHtml(s.detected_name)}" data-count="${s.count}">TVDB zuordnen</button>`;
|
|
html += `<button class="btn-small btn-secondary import-skip-btn" data-series="${escapeHtml(s.detected_name)}">Ueberspringen</button>`;
|
|
html += `</div>`;
|
|
}
|
|
container.innerHTML = html;
|
|
|
|
// Event-Listener hinzufuegen
|
|
container.querySelectorAll(".import-assign-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
openSeriesAssignModal(btn.dataset.series, parseInt(btn.dataset.count));
|
|
});
|
|
});
|
|
container.querySelectorAll(".import-skip-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
skipImportSeries(btn.dataset.series);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function finishSeriesAssignment() {
|
|
// Alle Serien zugeordnet -> Job-Status abrufen und Konflikte anzeigen
|
|
if (!currentImportJobId) return;
|
|
try {
|
|
const resp = await fetch(`/api/library/import/${currentImportJobId}`);
|
|
const data = await resp.json();
|
|
|
|
document.getElementById("import-series-assign").style.display = "none";
|
|
document.getElementById("import-preview").style.display = "";
|
|
renderImportItems(data);
|
|
} catch (e) {
|
|
showToast("Fehler: " + e, "error");
|
|
}
|
|
}
|
|
|
|
function renderImportItems(data) {
|
|
const items = data.items || [];
|
|
const job = data.job || {};
|
|
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 unresolvedConflicts = items.filter(i => i.status === "conflict" && !i.user_action).length;
|
|
const pending = items.filter(i => i.status === "pending").length;
|
|
const pendingSeries = items.filter(i => i.status === "pending_series").length;
|
|
const skipped = items.filter(i => i.status === "skipped").length;
|
|
|
|
document.getElementById("import-info").textContent =
|
|
`${items.length} Dateien: ${matched} bereit, ${conflicts} Konflikte, ${skipped} uebersprungen`;
|
|
|
|
// 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" ||
|
|
i.status === "pending_series"
|
|
);
|
|
const btn = document.getElementById("btn-start-import");
|
|
btn.disabled = hasUnresolved;
|
|
btn.title = hasUnresolved
|
|
? `${unresolvedConflicts + pending + pendingSeries} Dateien muessen erst bearbeitet werden`
|
|
: "Import starten";
|
|
|
|
if (!items.length) {
|
|
list.innerHTML = '<div class="loading-msg">Keine Dateien gefunden</div>';
|
|
return;
|
|
}
|
|
|
|
let html = "";
|
|
|
|
// Massen-Aktionen fuer Konflikte wenn welche vorhanden
|
|
if (unresolvedConflicts > 0) {
|
|
html += '<div style="padding:0.6rem;margin-bottom:0.5rem;background:#2a2010;border:1px solid #443020;border-radius:6px">';
|
|
html += `<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">`;
|
|
html += `<span style="font-weight:600;color:#ffcc80">${unresolvedConflicts} Konflikte</span>`;
|
|
html += `<button class="btn-small btn-primary" onclick="resolveAllConflicts('overwrite')">Alle ueberschreiben</button>`;
|
|
html += `<button class="btn-small btn-secondary" onclick="resolveAllConflicts('skip')">Alle ueberspringen</button>`;
|
|
html += `<label style="display:flex;align-items:center;gap:0.3rem;margin-left:auto">`;
|
|
html += `<input type="checkbox" id="import-overwrite-all" onchange="setOverwriteMode(this.checked)"${job.overwrite_all ? ' checked' : ''}>`;
|
|
html += `<span>Immer ueberschreiben</span></label>`;
|
|
html += `</div></div>`;
|
|
}
|
|
|
|
// Tabelle mit allen Items
|
|
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>';
|
|
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"
|
|
: item.status === "pending_series" ? "status-badge error"
|
|
: item.status === "skipped" ? "status-badge"
|
|
: "status-badge";
|
|
let statusText = item.status === "conflict" ? "Konflikt"
|
|
: item.status === "matched" ? "OK"
|
|
: item.status === "done" ? "Fertig"
|
|
: item.status === "skipped" ? "Skip"
|
|
: item.status === "pending" ? "Nicht erkannt"
|
|
: item.status === "pending_series" ? "Serie fehlt"
|
|
: item.status;
|
|
if (item.user_action === "overwrite") statusText = "Ueberschreiben";
|
|
if (item.user_action === "skip") statusText = "Skip";
|
|
|
|
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")}`
|
|
: "-";
|
|
|
|
const rowClass = item.status === "conflict" && !item.user_action ? "row-conflict"
|
|
: (item.status === "pending" || item.status === "pending_series") ? "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 && !item.user_action ? `<div class="text-muted" style="font-size:0.7rem">${escapeHtml(item.conflict_reason)}</div>` : ""}
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
list.innerHTML = html;
|
|
}
|
|
|
|
// Massen-Aktionen fuer Konflikte
|
|
async function resolveAllConflicts(action) {
|
|
if (!currentImportJobId) return;
|
|
try {
|
|
const resp = await fetch(`/api/library/import/${currentImportJobId}/resolve-all-conflicts`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({action}),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
return;
|
|
}
|
|
showToast(`${data.updated} Konflikte: ${action}`, "success");
|
|
refreshImportPreview();
|
|
} catch (e) {
|
|
showToast("Fehler: " + e, "error");
|
|
}
|
|
}
|
|
|
|
async function setOverwriteMode(overwrite) {
|
|
if (!currentImportJobId) return;
|
|
try {
|
|
await fetch(`/api/library/import/${currentImportJobId}/overwrite-mode`, {
|
|
method: "PUT",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({overwrite}),
|
|
});
|
|
if (overwrite) {
|
|
refreshImportPreview();
|
|
}
|
|
} catch (e) {
|
|
showToast("Fehler: " + e, "error");
|
|
}
|
|
}
|
|
|
|
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 => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
// === Import-Zuordnungs-Modal (Einzel + Serie) ===
|
|
|
|
let _assignItemId = null;
|
|
let _assignTvdbId = null;
|
|
let _assignSeriesName = "";
|
|
let _assignSearchTimer = null;
|
|
let _assignSeriesMode = false; // true = Serien-Zuordnung (alle Folgen)
|
|
let _assignDetectedSeries = ""; // Original detected_series fuer Batch
|
|
|
|
function openImportAssignModal(itemId, filename) {
|
|
_assignItemId = itemId;
|
|
_assignTvdbId = null;
|
|
_assignSeriesName = "";
|
|
_assignSeriesMode = false;
|
|
_assignDetectedSeries = "";
|
|
|
|
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 = "";
|
|
// Staffel/Episode einblenden (Einzelmodus)
|
|
const seFields = document.getElementById("import-assign-se-fields");
|
|
if (seFields) seFields.style.display = "";
|
|
document.getElementById("import-assign-search").focus();
|
|
}
|
|
|
|
function openSeriesAssignModal(detectedSeries, count) {
|
|
_assignItemId = null;
|
|
_assignTvdbId = null;
|
|
_assignSeriesName = "";
|
|
_assignSeriesMode = true;
|
|
_assignDetectedSeries = detectedSeries;
|
|
|
|
const modal = document.getElementById("import-assign-modal");
|
|
modal.style.display = "flex";
|
|
document.getElementById("import-assign-filename").textContent =
|
|
`Serie: ${detectedSeries} (${count} Folgen)`;
|
|
document.getElementById("import-assign-search").value = detectedSeries;
|
|
document.getElementById("import-assign-results").innerHTML = "";
|
|
document.getElementById("import-assign-selected").style.display = "none";
|
|
// Staffel/Episode verstecken (Serienmodus)
|
|
const seFields = document.getElementById("import-assign-se-fields");
|
|
if (seFields) seFields.style.display = "none";
|
|
// Sofort TVDB-Suche starten
|
|
searchAssignTvdb();
|
|
}
|
|
|
|
function closeImportAssignModal() {
|
|
document.getElementById("import-assign-modal").style.display = "none";
|
|
_assignItemId = null;
|
|
_assignSeriesMode = false;
|
|
_assignDetectedSeries = "";
|
|
// Staffel/Episode wieder einblenden
|
|
const seFields = document.getElementById("import-assign-se-fields");
|
|
if (seFields) seFields.style.display = "";
|
|
}
|
|
|
|
async function skipImportSeries(detectedSeries) {
|
|
if (!currentImportJobId) return;
|
|
if (!await showConfirm(`Alle Folgen von "<strong>${escapeHtml(detectedSeries)}</strong>" ueberspringen?`, {title: "Serie ueberspringen", okText: "Alle Skip"})) return;
|
|
|
|
try {
|
|
const r = await fetch(`/api/library/import/${currentImportJobId}/skip-series`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({detected_series: detectedSeries}),
|
|
});
|
|
const data = await r.json();
|
|
if (data.error) { showToast("Fehler: " + data.error, "error"); return; }
|
|
showToast(`${data.skipped} Folgen uebersprungen`, "success");
|
|
|
|
// Pruefen ob wir in der Serien-Zuordnung sind
|
|
const seriesAssignEl = document.getElementById("import-series-assign");
|
|
if (seriesAssignEl && seriesAssignEl.style.display !== "none") {
|
|
// Noch in Phase 2 -> Serien-Liste aktualisieren
|
|
loadPendingSeries();
|
|
} else {
|
|
// In Konflikt-Ansicht -> Preview aktualisieren
|
|
refreshImportPreview();
|
|
}
|
|
} catch (e) {
|
|
showToast("Fehler: " + e, "error");
|
|
}
|
|
}
|
|
|
|
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() {
|
|
const manualName = document.getElementById("import-assign-search").value.trim();
|
|
const seriesName = _assignSeriesName || manualName;
|
|
|
|
if (!seriesName) { showToast("Serie auswaehlen oder Namen eingeben", "error"); return; }
|
|
|
|
const btn = document.querySelector("#import-assign-modal .btn-primary");
|
|
btn.disabled = true;
|
|
|
|
if (_assignSeriesMode && _assignDetectedSeries) {
|
|
// Serien-Zuordnung: Alle Folgen auf einmal (neue API)
|
|
btn.textContent = "Zuordne alle...";
|
|
|
|
fetch(`/api/library/import/${currentImportJobId}/assign-series`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
detected_series: _assignDetectedSeries,
|
|
tvdb_id: _assignTvdbId || null,
|
|
tvdb_name: seriesName,
|
|
}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.disabled = false;
|
|
btn.textContent = "Zuordnen";
|
|
if (data.error) { showToast("Fehler: " + data.error, "error"); return; }
|
|
showToast(`${data.updated} Folgen zugeordnet`, "success");
|
|
closeImportAssignModal();
|
|
|
|
// Pruefen ob noch Serien zugeordnet werden muessen
|
|
if (data.remaining_series > 0) {
|
|
// Noch Serien uebrig -> Liste aktualisieren
|
|
loadPendingSeries();
|
|
} else {
|
|
// Alle Serien zugeordnet -> weiter zu Konflikten
|
|
finishSeriesAssignment();
|
|
}
|
|
})
|
|
.catch(e => { btn.disabled = false; btn.textContent = "Zuordnen"; showToast("Fehler: " + e, "error"); });
|
|
return;
|
|
}
|
|
|
|
// Einzel-Zuordnung
|
|
if (!_assignItemId) return;
|
|
const season = parseInt(document.getElementById("import-assign-season").value);
|
|
const episode = parseInt(document.getElementById("import-assign-episode").value);
|
|
if (isNaN(season) || isNaN(episode)) { showToast("Staffel und Episode eingeben", "error"); return; }
|
|
|
|
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) { showToast("Fehler: " + data.error, "error"); return; }
|
|
closeImportAssignModal();
|
|
refreshImportPreview();
|
|
})
|
|
.catch(e => { btn.disabled = false; btn.textContent = "Zuordnen"; showToast("Fehler: " + e, "error"); });
|
|
}
|
|
|
|
function skipImportItem(itemId) {
|
|
fetch(`/api/library/import/items/${itemId}/skip`, {method: "POST"})
|
|
.then(r => r.json())
|
|
.then(() => refreshImportPreview())
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
function refreshImportPreview() {
|
|
if (!currentImportJobId) return;
|
|
fetch(`/api/library/import/${currentImportJobId}`)
|
|
.then(r => r.json())
|
|
.then(data => renderImportItems(data))
|
|
.catch(() => {});
|
|
}
|
|
|
|
let importPollingId = null;
|
|
let _importWsActive = false; // WebSocket liefert Updates?
|
|
|
|
async function executeImport() {
|
|
if (!currentImportJobId) return;
|
|
|
|
// Job-ID merken bevor resetImport() sie loescht
|
|
const jobId = currentImportJobId;
|
|
|
|
// Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken
|
|
closeImportModal();
|
|
resetImport();
|
|
|
|
// Starte Import (non-blocking - Server antwortet sofort)
|
|
fetch(`/api/library/import/${jobId}/execute`, {method: "POST"});
|
|
}
|
|
|
|
// WebSocket-Handler fuer Import-Fortschritt
|
|
function handleImportWS(data) {
|
|
if (!data || !data.job_id) return;
|
|
|
|
_importWsActive = true;
|
|
stopImportPolling();
|
|
|
|
// Import-Progress-Container sichtbar machen
|
|
const progressEl = document.getElementById("import-progress");
|
|
if (progressEl) progressEl.style.display = "";
|
|
|
|
// Pro Job ein eigenes Element erstellen/finden
|
|
const container = document.getElementById("import-jobs-container");
|
|
if (!container) return;
|
|
let jobEl = document.getElementById("import-job-" + data.job_id);
|
|
if (!jobEl) {
|
|
jobEl = document.createElement("div");
|
|
jobEl.id = "import-job-" + data.job_id;
|
|
jobEl.className = "import-job-progress";
|
|
const jobName = data.source_path || data.job_name || "Job #" + data.job_id;
|
|
const shortName = jobName.split("/").pop() || jobName;
|
|
jobEl.innerHTML =
|
|
'<div class="import-job-header">' +
|
|
'<span class="import-job-name" title="' + escapeHtml(jobName) + '">' + escapeHtml(shortName) + '</span>' +
|
|
'<span class="import-job-status"></span>' +
|
|
'</div>' +
|
|
'<div class="progress-container"><div class="progress-bar import-job-bar"></div></div>' +
|
|
'<span class="text-muted import-job-text">Starte...</span>';
|
|
container.appendChild(jobEl);
|
|
}
|
|
|
|
const bar = jobEl.querySelector(".import-job-bar");
|
|
const statusText = jobEl.querySelector(".import-job-text");
|
|
const statusBadge = jobEl.querySelector(".import-job-status");
|
|
|
|
const status = data.status || "";
|
|
const total = data.total || 1;
|
|
const processed = data.processed || 0;
|
|
const curFile = data.current_file || "";
|
|
const bytesDone = data.bytes_done || 0;
|
|
const bytesTotal = data.bytes_total || 0;
|
|
|
|
// Prozent berechnen
|
|
let pct = (processed / total) * 100;
|
|
if (bytesTotal > 0 && processed < total) {
|
|
pct += (bytesDone / bytesTotal) * (100 / total);
|
|
}
|
|
pct = Math.min(Math.round(pct), 100);
|
|
if (bar) bar.style.width = pct + "%";
|
|
|
|
if (status === "analyzing") {
|
|
if (statusText) statusText.textContent =
|
|
`Analysiere: ${processed} / ${total} - ${curFile}`;
|
|
if (statusBadge) statusBadge.textContent = "Analyse";
|
|
} else if (status === "embedding") {
|
|
if (statusText) statusText.textContent =
|
|
`Metadaten: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`;
|
|
if (statusBadge) statusBadge.textContent = "Metadaten";
|
|
} else if (status === "importing") {
|
|
let txt = `${processed} / ${total} Dateien`;
|
|
if (curFile && bytesTotal > 0 && processed < total) {
|
|
const curPct = Math.round((bytesDone / bytesTotal) * 100);
|
|
txt += ` - ${curFile.substring(0, 40)}... (${formatSize(bytesDone)}/${formatSize(bytesTotal)})`;
|
|
} else {
|
|
txt += ` (${pct}%)`;
|
|
}
|
|
if (statusText) statusText.textContent = txt;
|
|
if (statusBadge) statusBadge.textContent = pct + "%";
|
|
} else if (status === "done" || status === "error") {
|
|
if (bar) bar.style.width = "100%";
|
|
if (status === "done") {
|
|
if (statusBadge) { statusBadge.textContent = "Fertig"; statusBadge.style.color = "#4caf50"; }
|
|
} else {
|
|
if (statusBadge) { statusBadge.textContent = "Fehler"; statusBadge.style.color = "#f44336"; }
|
|
}
|
|
|
|
// Ergebnis per REST holen
|
|
fetch(`/api/library/import/${data.job_id}`)
|
|
.then(r => r.json())
|
|
.then(result => {
|
|
const items = result.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;
|
|
if (statusText) statusText.textContent =
|
|
`${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
|
|
|
|
const job = result.job;
|
|
if (job && job.target_library_id && imported > 0) {
|
|
fetch(`/api/library/scan/${job.target_library_id}`, {method: "POST"})
|
|
.then(() => setTimeout(() => {
|
|
loadSectionData(job.target_library_id);
|
|
loadStats();
|
|
}, 2000))
|
|
.catch(() => { reloadAllSections(); loadStats(); });
|
|
} else {
|
|
reloadAllSections();
|
|
loadStats();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
|
|
// WebSocket-Handler fuer Scan-Fortschritt
|
|
// Aktualisiert Bibliothek wenn Scan fertig ist (globaler Progress-Balken in base.html)
|
|
function handleScanWS(data) {
|
|
if (!data) return;
|
|
if (data.status === "idle") {
|
|
loadStats();
|
|
reloadAllSections();
|
|
}
|
|
}
|
|
|
|
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();
|
|
return;
|
|
}
|
|
|
|
const job = data.job;
|
|
if (!job) return;
|
|
|
|
// handleImportWS wiederverwenden (rendert in den neuen Container)
|
|
handleImportWS({
|
|
job_id: currentImportJobId,
|
|
status: job.status,
|
|
total: job.total_files || 1,
|
|
processed: job.processed_files || 0,
|
|
current_file: job.current_file_name || "",
|
|
bytes_done: job.current_file_bytes || 0,
|
|
bytes_total: job.current_file_total || 0,
|
|
source_path: job.source_path || "",
|
|
});
|
|
|
|
// Fertig? (handleImportWS uebernimmt Ergebnis-Laden und Scan)
|
|
if (job.status === "done" || job.status === "error") {
|
|
stopImportPolling();
|
|
}
|
|
} catch (e) {
|
|
console.error("Import-Polling Fehler:", e);
|
|
}
|
|
}, 5000); // 5s statt 500ms - nur Fallback
|
|
}
|
|
|
|
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) {
|
|
// String fuer HTML-Attribute (onclick) sicher machen
|
|
// Escaped: ' (fuer JS-String), &, <
|
|
return (str || "")
|
|
.replace(/\\/g, "\\\\")
|
|
.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 ===
|
|
|
|
async function deleteVideo(videoId, title, context) {
|
|
if (!await showConfirm(`"<strong>${escapeHtml(title)}</strong>" wirklich loeschen?`, {title: "Video loeschen", detail: "Datei wird unwiderruflich entfernt!", okText: "Loeschen", icon: "danger", danger: true})) 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");
|
|
|
|
// Zeile aus Tabelle entfernen (ohne Modal neu zu laden)
|
|
const row = document.querySelector(`tr[data-video-id="${videoId}"]`);
|
|
if (row) {
|
|
row.remove();
|
|
} else {
|
|
// Fallback: Ansicht komplett aktualisieren
|
|
if (context === "series" && currentSeriesId) {
|
|
openSeriesDetail(currentSeriesId);
|
|
} else if (context === "movie" && currentMovieId) {
|
|
openMovieDetail(currentMovieId);
|
|
} else {
|
|
reloadAllSections();
|
|
}
|
|
}
|
|
loadStats();
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
// === Batch-Thumbnail-Generierung ===
|
|
|
|
async function generateThumbnails() {
|
|
// Status pruefen
|
|
const status = await fetch("/api/library/thumbnail-status").then(r => r.json());
|
|
if (status.missing === 0) {
|
|
showToast("Alle " + status.total + " Videos haben bereits Thumbnails", "info");
|
|
return;
|
|
}
|
|
|
|
if (!await showConfirm(
|
|
status.missing + " von " + status.total + " Videos haben noch kein Thumbnail. Jetzt generieren?",
|
|
{title: "Thumbnails generieren", detail: "Die Generierung laeuft im Hintergrund per ffmpeg.", okText: "Starten", icon: "info"}
|
|
)) return;
|
|
|
|
fetch("/api/library/generate-thumbnails", {method: "POST"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === "running") {
|
|
showToast("Thumbnail-Generierung laeuft bereits", "info");
|
|
} else {
|
|
showToast("Thumbnail-Generierung gestartet", "success");
|
|
}
|
|
// Globalen Fortschrittsbalken anzeigen und Polling starten
|
|
_gpShow("thumbnails", "Thumbnails", "Starte...", 0);
|
|
pollThumbnailStatus();
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
function pollThumbnailStatus() {
|
|
const interval = setInterval(() => {
|
|
fetch("/api/library/thumbnail-status")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const pct = data.total > 0 ? Math.round((data.generated / data.total) * 100) : 0;
|
|
_gpShow("thumbnails", "Thumbnails", data.generated + " / " + data.total, pct);
|
|
if (!data.running) {
|
|
clearInterval(interval);
|
|
_gpHideDelayed("thumbnails");
|
|
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
|
|
}
|
|
})
|
|
.catch(() => clearInterval(interval));
|
|
}, 2000);
|
|
}
|