';
return html;
}
function renderPagination(total, page, pages, pathId, tabType) {
let html = '
';
html += '
';
if (pages <= 1) {
html += `${total} Videos`;
} else {
html += `${total} Videos | Seite ${page}/${pages} `;
if (page > 1) {
html += ` `;
}
if (page < pages) {
html += ``;
}
}
html += '
';
html += `
`;
html += '
';
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 '
Keine Serien gefunden
';
let html = '
';
for (const s of series) {
const poster = s.poster_url
? ``
: '
';
});
}
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 = '
';
if (series.poster_url) {
html += ``;
}
html += '
';
if (series.overview) html += `
${escapeHtml(series.overview)}
`;
html += '
';
if (series.first_aired) html += `${series.first_aired}`;
if (series.status) html += `${series.status}`;
html += `${series.local_episodes || 0} lokal`;
if (series.total_episodes) html += `${series.total_episodes} gesamt`;
if (series.missing_episodes > 0) html += `${series.missing_episodes} fehlend`;
html += '
';
// 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 += ``;
html += `Staffel ${sNum || "Unbekannt"} (${sData.local.length} vorhanden`;
if (sData.missing.length) html += `, ${sData.missing.length} fehlend`;
html += ')';
html += '
';
html += '
Nr
Titel
Aufl.
Codec
Audio
Aktion
';
html += '
';
const allEps = [];
for (const ep of sData.local) allEps.push({...ep, _type: "local"});
for (const ep of sData.missing) allEps.push({...ep, _type: "missing"});
allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0));
for (const ep of allEps) {
if (ep._type === "missing") {
html += `
';
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 += `
'; });
}
function closeSeriesModal() {
document.getElementById("series-modal").style.display = "none";
currentSeriesId = null;
}
// === Serien-Aktionen ===
function tvdbRefresh() {
if (!currentSeriesId) return;
fetch(`/api/library/series/${currentSeriesId}/tvdb-refresh`, {method: "POST"})
.then(r => r.json())
.then(data => {
if (data.error) alert("Fehler: " + data.error);
else { alert("TVDB aktualisiert: " + (data.name || "")); openSeriesDetail(currentSeriesId); }
})
.catch(e => alert("Fehler: " + e));
}
function tvdbUnlink() {
if (!currentSeriesId || !confirm("TVDB-Zuordnung wirklich loesen?")) return;
fetch(`/api/library/series/${currentSeriesId}/tvdb`, {method: "DELETE"})
.then(r => r.json())
.then(data => {
if (data.error) alert("Fehler: " + data.error);
else { closeSeriesModal(); reloadAllSections(); }
})
.catch(e => alert("Fehler: " + e));
}
function downloadMetadata() {
if (!currentSeriesId) return;
const btn = document.getElementById("btn-metadata-dl");
btn.textContent = "Laden...";
btn.disabled = true;
fetch(`/api/library/series/${currentSeriesId}/metadata-download`, {method: "POST"})
.then(r => r.json())
.then(data => {
btn.textContent = "Metadaten laden";
btn.disabled = false;
if (data.error) alert("Fehler: " + data.error);
else alert(`${data.downloaded || 0} Dateien heruntergeladen, ${data.errors || 0} Fehler`);
})
.catch(e => { btn.textContent = "Metadaten laden"; btn.disabled = false; alert("Fehler: " + e); });
}
function deleteSeries(withFiles) {
if (!currentSeriesId) return;
if (withFiles) {
if (!confirm("ACHTUNG: Serie komplett loeschen?\n\nAlle Dateien und Ordner werden UNWIDERRUFLICH geloescht!")) return;
if (!confirm("Wirklich sicher? Dieser Vorgang kann NICHT rueckgaengig gemacht werden!")) return;
} else {
if (!confirm("Serie aus der Datenbank loeschen?\n(Dateien bleiben erhalten)")) return;
}
const url = withFiles
? `/api/library/series/${currentSeriesId}?delete_files=1`
: `/api/library/series/${currentSeriesId}`;
fetch(url, {method: "DELETE"})
.then(r => r.json())
.then(data => {
if (data.error) { alert("Fehler: " + data.error); return; }
let msg = "Serie aus DB geloescht.";
if (data.deleted_folder) msg += "\nOrdner geloescht: " + data.deleted_folder;
if (data.folder_error) msg += "\nOrdner-Fehler: " + data.folder_error;
alert(msg);
closeSeriesModal();
reloadAllSections();
loadStats();
})
.catch(e => alert("Fehler: " + e));
}
// === Bestaetigungs-Dialog ===
let pendingConfirmAction = null;
function showDeleteFolderDialog(folderPath, pathId, videoCount) {
const folderName = folderPath.split('/').pop();
document.getElementById("confirm-title").textContent = "Ordner loeschen";
document.getElementById("confirm-icon").innerHTML = `
`;
document.getElementById("confirm-message").innerHTML = `
${escapeHtml(folderName)}
wirklich loeschen?`;
document.getElementById("confirm-detail").innerHTML = `
${videoCount} Video${videoCount !== 1 ? 's' : ''} werden unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden!`;
document.getElementById("confirm-btn-ok").textContent = "Endgueltig loeschen";
document.getElementById("confirm-modal").style.display = "flex";
pendingConfirmAction = () => executeDeleteFolder(folderPath, pathId);
}
function closeConfirmModal() {
document.getElementById("confirm-modal").style.display = "none";
pendingConfirmAction = null;
}
function confirmAction() {
if (pendingConfirmAction) {
pendingConfirmAction();
}
closeConfirmModal();
}
function executeDeleteFolder(folderPath, pathId) {
fetch("/api/library/delete-folder", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({folder_path: folderPath})
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
return;
}
const msg = `${data.deleted_files || 0} Dateien geloescht`;
showToast(msg, "success");
if (pathId) loadSectionData(pathId);
loadStats();
})
.catch(e => showToast("Fehler: " + e, "error"));
}
function showToast(message, type = "info") {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.classList.add("show"), 10);
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// === Film-Detail ===
function openMovieDetail(movieId) {
if (event) event.stopPropagation();
currentMovieId = movieId;
document.getElementById("movie-modal").style.display = "flex";
fetch(`/api/library/movies/${movieId}`)
.then(r => r.json())
.then(data => {
document.getElementById("movie-modal-title").textContent =
data.title || data.folder_name;
document.getElementById("movie-modal-genres").textContent =
data.genres || "";
// Aktions-Buttons
document.getElementById("btn-movie-tvdb-unlink").style.display =
data.tvdb_id ? "" : "none";
let html = '
';
if (data.poster_url) {
html += ``;
}
html += '
';
if (data.overview) html += `
${escapeHtml(data.overview)}
`;
html += '
';
if (data.year) html += `${data.year}`;
if (data.runtime) html += `${data.runtime} min`;
if (data.status) html += `${data.status}`;
html += `${data.video_count || 0} Dateien`;
if (data.total_size) html += `${formatSize(data.total_size)}`;
html += '
';
// Video-Dateien des Films
const videos = data.videos || [];
if (videos.length) {
html += '
Video-Dateien
';
html += '
';
html += '
Datei
Aufl.
Codec
Audio
Groesse
Dauer
Aktion
';
html += '
';
for (const v of videos) {
const audioInfo = (v.audio_tracks || []).map(a => {
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
return `${lang} ${channelLayout(a.channels)}`;
}).join(" ");
const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-";
const movieTitle = v.file_name || "Video";
html += `
`; });
}
let movieTvdbSearchTimer = null;
function debounceMovieTvdbSearch() {
if (movieTvdbSearchTimer) clearTimeout(movieTvdbSearchTimer);
movieTvdbSearchTimer = setTimeout(searchMovieTvdb, 500);
}
// === Filter ===
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");
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() {
for (const pid of Object.keys(sectionStates)) {
sectionStates[pid].page = 1;
loadSectionData(parseInt(pid));
}
}
function debounceFilter() {
if (filterTimeout) clearTimeout(filterTimeout);
filterTimeout = setTimeout(applyFilters, 400);
}
// === Scan ===
function startScan() {
const progress = document.getElementById("scan-progress");
progress.style.display = "block";
document.getElementById("scan-status").textContent = "Scan wird gestartet...";
document.getElementById("scan-bar").style.width = "0%";
fetch("/api/library/scan", {method: "POST"})
.then(() => pollScanStatus())
.catch(e => { document.getElementById("scan-status").textContent = "Fehler: " + e; });
}
function scanSinglePath(pathId) {
const progress = document.getElementById("scan-progress");
progress.style.display = "block";
document.getElementById("scan-status").textContent = "Scan wird gestartet...";
document.getElementById("scan-bar").style.width = "0%";
fetch(`/api/library/scan/${pathId}`, {method: "POST"})
.then(() => pollScanStatus())
.catch(e => { document.getElementById("scan-status").textContent = "Fehler: " + e; });
}
function pollScanStatus() {
const interval = setInterval(() => {
fetch("/api/library/scan-status")
.then(r => r.json())
.then(data => {
if (data.status === "idle") {
clearInterval(interval);
document.getElementById("scan-progress").style.display = "none";
loadStats();
reloadAllSections();
} else {
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
document.getElementById("scan-bar").style.width = pct + "%";
document.getElementById("scan-status").textContent =
`Scanne: ${data.current || ""} (${data.done || 0}/${data.total || 0})`;
}
})
.catch(() => clearInterval(interval));
}, 1000);
}
function reloadAllSections() {
for (const pid of Object.keys(sectionStates)) {
loadSectionData(parseInt(pid));
}
}
// === TVDB Auto-Match (Review-Modus) ===
let tvdbReviewData = []; // Vorschlaege die noch geprueft werden muessen
function startAutoMatch() {
if (!confirm("TVDB-Vorschlaege fuer alle nicht-zugeordneten Serien und Filme sammeln?\n\nDas kann einige Minuten dauern. Du kannst danach jeden Vorschlag pruefen und bestaetigen.")) return;
const progress = document.getElementById("auto-match-progress");
progress.style.display = "block";
document.getElementById("auto-match-status").textContent = "Suche TVDB-Vorschlaege...";
document.getElementById("auto-match-bar").style.width = "0%";
fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"})
.then(r => r.json())
.then(data => {
if (data.error) {
document.getElementById("auto-match-status").textContent = "Fehler: " + data.error;
setTimeout(() => { progress.style.display = "none"; }, 3000);
return;
}
pollAutoMatchStatus();
})
.catch(e => {
document.getElementById("auto-match-status").textContent = "Fehler: " + e;
});
}
function pollAutoMatchStatus() {
const progress = document.getElementById("auto-match-progress");
const interval = setInterval(() => {
fetch("/api/library/tvdb-auto-match-status")
.then(r => r.json())
.then(data => {
const bar = document.getElementById("auto-match-bar");
const status = document.getElementById("auto-match-status");
if (data.phase === "done") {
clearInterval(interval);
bar.style.width = "100%";
const suggestions = data.suggestions || [];
// Nur Items mit mindestens einem Vorschlag anzeigen
const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0);
const noResults = suggestions.length - withSuggestions.length;
status.textContent = `${withSuggestions.length} Vorschlaege gefunden, ${noResults} ohne Ergebnis`;
setTimeout(() => {
progress.style.display = "none";
}, 2000);
if (withSuggestions.length > 0) {
openTvdbReviewModal(withSuggestions);
}
} else if (data.phase === "error") {
clearInterval(interval);
status.textContent = "Fehler beim Sammeln der Vorschlaege";
setTimeout(() => { progress.style.display = "none"; }, 3000);
} else if (!data.active && data.phase !== "done") {
clearInterval(interval);
progress.style.display = "none";
} else {
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
bar.style.width = pct + "%";
const phase = data.phase === "series" ? "Serien" : "Filme";
status.textContent = `${phase}: ${data.current || ""} (${data.done}/${data.total})`;
}
})
.catch(() => clearInterval(interval));
}, 1000);
}
// === TVDB Review-Modal ===
function openTvdbReviewModal(suggestions) {
tvdbReviewData = suggestions;
document.getElementById("tvdb-review-modal").style.display = "flex";
renderTvdbReviewList();
}
function closeTvdbReviewModal() {
document.getElementById("tvdb-review-modal").style.display = "none";
tvdbReviewData = [];
reloadAllSections();
loadStats();
}
function renderTvdbReviewList() {
const list = document.getElementById("tvdb-review-list");
const remaining = tvdbReviewData.filter(item => !item._confirmed && !item._skipped);
const confirmed = tvdbReviewData.filter(item => item._confirmed);
const skipped = tvdbReviewData.filter(item => item._skipped);
document.getElementById("tvdb-review-info").textContent =
`${remaining.length} offen, ${confirmed.length} zugeordnet, ${skipped.length} uebersprungen`;
if (!tvdbReviewData.length) {
list.innerHTML = '
Keine Vorschlaege vorhanden
';
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 = `Zugeordnet: ${escapeHtml(item._confirmedName || "")}`;
} else if (item._skipped) {
statusClass = "review-item-skipped";
statusHtml = 'Uebersprungen';
}
html += `
`;
html += `
`;
html += `${typeLabel}`;
html += `${escapeHtml(item.local_name)}`;
if (item.year) html += `(${item.year})`;
if (statusHtml) html += statusHtml;
if (!item._confirmed && !item._skipped) {
html += ``;
html += ``;
}
html += `
`;
// Vorschlaege anzeigen (nur wenn noch nicht bestaetigt/uebersprungen)
if (!item._confirmed && !item._skipped) {
html += `
`;
if (!item.suggestions || !item.suggestions.length) {
html += 'Keine Vorschlaege gefunden';
} else {
for (const s of item.suggestions) {
const poster = s.poster
? ``
: '
?
';
html += `
`;
html += poster;
html += `
`;
html += `${escapeHtml(s.name)}`;
if (s.year) html += ` (${s.year})`;
if (s.overview) html += `
${escapeHtml(s.overview)}
`;
html += `
`;
html += `
`;
}
}
// Manuelles Suchfeld (versteckt, wird bei Klick auf "Manuell suchen" angezeigt)
html += `
`;
html += ``;
html += ``;
html += ``;
html += `
`;
html += `
`;
}
html += `
`;
}
list.innerHTML = html;
}
function confirmReviewItem(index, tvdbId, name) {
const item = tvdbReviewData[index];
if (item._confirmed || item._skipped) return;
// Visuelles Feedback
const el = document.getElementById("review-item-" + index);
if (el) el.classList.add("review-item-loading");
fetch("/api/library/tvdb-confirm", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({id: item.id, type: item.type, tvdb_id: tvdbId}),
})
.then(r => r.json())
.then(data => {
if (data.error) {
alert("Fehler: " + data.error);
if (el) el.classList.remove("review-item-loading");
return;
}
item._confirmed = true;
item._confirmedName = data.name || name;
renderTvdbReviewList();
})
.catch(e => {
alert("Fehler: " + e);
if (el) el.classList.remove("review-item-loading");
});
}
function skipReviewItem(index) {
tvdbReviewData[index]._skipped = true;
renderTvdbReviewList();
}
function skipAllReviewItems() {
for (const item of tvdbReviewData) {
if (!item._confirmed) item._skipped = true;
}
renderTvdbReviewList();
}
function manualTvdbSearchReview(index) {
const el = document.getElementById("review-manual-" + index);
if (el) {
el.style.display = el.style.display === "none" ? "flex" : "none";
}
}
function executeManualReviewSearch(index) {
const item = tvdbReviewData[index];
const input = document.getElementById("review-search-input-" + index);
const results = document.getElementById("review-search-results-" + index);
const query = input ? input.value.trim() : "";
if (!query) return;
results.innerHTML = '
`;
return;
}
let html = '';
// Breadcrumb
html += '
';
html += `/mnt`;
for (const c of (data.breadcrumb || [])) {
if (c.path === "/mnt" || c.path.length < 5) continue;
html += ` / `;
html += `${escapeHtml(c.name)}`;
}
html += '
';
// Eltern-Ordner
const parts = data.current_path.split("/");
if (parts.length > 2) {
const parentPath = parts.slice(0, -1).join("/") || "/mnt";
html += `
🔙..
`;
}
// Unterordner: Einfachklick = auswaehlen, Doppelklick = navigieren
for (const f of (data.folders || [])) {
const meta = f.video_count > 0 ? `${f.video_count} Videos` : "";
html += `
📁${escapeHtml(f.name)}${meta}
`;
}
if (!data.folders?.length && !data.video_count) {
html += '