docker.videokonverter/video-konverter/app/static/js/library.js
data 99730f2f8f feat: VideoKonverter v3.1 - TV-App, Auth, Tizen, Log-API
TV-App (/tv/):
- Login mit bcrypt-Passwort-Hashing und DB-Sessions (30 Tage)
- Home (Weiterschauen, Serien, Filme), Serien-Detail mit Staffeln
- Film-Uebersicht und Detail, Fullscreen Video-Player
- Suche mit Live-Ergebnissen, Watch-Progress (alle 10s gespeichert)
- D-Pad/Fernbedienung-Navigation (FocusManager, Samsung Tizen Keys)
- PWA: manifest.json, Service Worker, Icons fuer Handy/Tablet
- Pro-User Berechtigungen (Serien, Filme, Admin, erlaubte Pfade)

Admin-Erweiterungen:
- QR-Code fuer TV-App URL
- User-Verwaltung (CRUD) mit Rechte-Konfiguration
- Log-API: GET /api/log?lines=100&level=INFO

Tizen-App (tizen-app/):
- Wrapper-App fuer Samsung Smart TVs (.wgt Paket)
- Einmalige Server-IP Eingabe, danach automatische Verbindung
- Installationsanleitung (INSTALL.md)

Bug-Fixes:
- executeImport: Job-ID vor resetImport() gesichert
- cursor(aiomysql.DictCursor) statt cursor(dict)
- DB-Spalten width/height statt video_width/video_height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:26:19 +01:00

3160 lines
128 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">&#128218;</span>
<span class="nav-path-name">Alle</span>
</div>`;
for (const lp of enabled) {
const icon = lp.media_type === 'series' ? '&#127916;' : '&#127910;';
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">&#9654;</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">&#10005;</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})">&larr;</button> `;
}
if (page < pages) {
html += `<button class="btn-small btn-secondary" onclick="loadSection${tabType === 'movies' ? 'Movies' : 'Videos'}(${pathId}, ${page + 1})">&rarr;</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>`;
html += `<div class="series-card" onclick="openSeriesDetail(${s.id})">
${poster}
<div class="series-info">
<h4 title="${escapeHtml(s.folder_path || '')}">${escapeHtml(s.title || s.folder_name)}</h4>
${genres}
<div class="series-meta">
<span>${s.local_episodes || 0} Episoden</span>
${missing}
${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">&#128193;</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));
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 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() || "-";
html += `<tr data-video-id="${ep.id}">
<td>${ep.episode_number || "-"}</td>
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}</td>
<td>${res}</td>
<td><span class="tag codec">${ep.video_codec || "-"}</span></td>
<td><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">&#9654;</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">&#10005;</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">&#9654;</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">&#10005;</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;
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));
}, 5000); // 5s Fallback (WS liefert Live-Updates)
}
// === 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}">${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">&times;</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">&#128281;</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">&#128193;</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;
// Nur Updates fuer aktuellen Job
if (data.job_id !== currentImportJobId) return;
_importWsActive = true;
// Polling abschalten wenn WS liefert
stopImportPolling();
const progressEl = document.getElementById("import-progress");
if (progressEl) progressEl.style.display = "";
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);
const bar = document.getElementById("import-bar");
const statusText = document.getElementById("import-status-text");
if (bar) bar.style.width = pct + "%";
if (status === "analyzing") {
if (statusText) statusText.textContent =
`Analysiere: ${processed} / ${total} - ${curFile}`;
} else if (status === "embedding") {
if (statusText) statusText.textContent =
`Metadaten schreiben: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`;
} else if (status === "importing") {
let txt = `Importiere: ${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)}, ${curPct}%)`;
} else {
txt += ` (${pct}%)`;
}
if (statusText) statusText.textContent = txt;
} else if (status === "done" || status === "error") {
if (bar) bar.style.width = "100%";
if (statusText) statusText.textContent =
status === "done"
? `Import abgeschlossen (${processed} Dateien)`
: `Import mit Fehlern beendet`;
// Ergebnis per REST holen fuer Details
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 =
`Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
// Ziel-Pfad scannen
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();
document.getElementById("import-status-text").textContent = "Fehler: " + data.error;
return;
}
const job = data.job;
if (!job) return;
const total = job.total_files || 1;
const done = job.processed_files || 0;
// Byte-Fortschritt der aktuellen Datei
const curFile = job.current_file_name || "";
const curBytes = job.current_file_bytes || 0;
const curTotal = job.current_file_total || 0;
// Prozent: fertige Dateien + anteilig aktuelle Datei
let pct = (done / total) * 100;
if (curTotal > 0 && done < total) {
pct += (curBytes / curTotal) * (100 / total);
}
pct = Math.min(Math.round(pct), 100);
document.getElementById("import-bar").style.width = pct + "%";
// Status-Text mit Byte-Fortschritt
let statusText = `Importiere: ${done} / ${total} Dateien`;
if (curFile && curTotal > 0 && done < total) {
const curPct = Math.round((curBytes / curTotal) * 100);
statusText += ` - ${curFile.substring(0, 40)}... (${formatSize(curBytes)} / ${formatSize(curTotal)}, ${curPct}%)`;
} else {
statusText += ` (${pct}%)`;
}
document.getElementById("import-status-text").textContent = statusText;
// Fertig?
if (job.status === "done" || job.status === "error") {
stopImportPolling();
document.getElementById("import-bar").style.width = "100%";
// Zaehle Ergebnisse
const items = data.items || [];
const imported = items.filter(i => i.status === "done").length;
const errors = items.filter(i => i.status === "error").length;
const skipped = items.filter(i => i.status === "skipped").length;
document.getElementById("import-status-text").textContent =
`Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
// Nur Ziel-Pfad scannen und neu laden (statt alles)
const targetPathId = job.target_library_id;
if (targetPathId && imported > 0) {
fetch(`/api/library/scan/${targetPathId}`, {method: "POST"})
.then(() => {
setTimeout(() => {
loadSectionData(targetPathId);
loadStats();
}, 2000);
})
.catch(() => {
reloadAllSections();
loadStats();
});
} else {
reloadAllSections();
loadStats();
}
}
} 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, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttr(str) {
// String fuer HTML-Attribute (onclick) sicher machen
// Escaped: ' (fuer JS-String), &, <
return (str || "")
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;");
}
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"));
}