const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); let libraries = []; async function api(path, opts = {}) { const resp = await fetch(path, { headers: { "Content-Type": "application/json" }, ...opts, }); if (!resp.ok) { let msg = resp.statusText; try { msg = (await resp.json()).detail || msg; } catch {} throw new Error(msg); } return resp.json(); } function toast(msg, isError = false) { const t = $("#toast"); t.textContent = msg; t.className = isError ? "error" : ""; t.hidden = false; setTimeout(() => (t.hidden = true), 4000); } function esc(s) { const d = document.createElement("div"); d.textContent = s ?? ""; return d.innerHTML; } // ---- navigation ---- $$("nav button").forEach((btn) => btn.addEventListener("click", () => { $$("nav button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); $$("main > section").forEach((s) => (s.hidden = true)); $("#view-" + btn.dataset.view).hidden = false; if (btn.dataset.view === "missing") loadRequests("missing"); if (btn.dataset.view === "imported") loadRequests("imported"); if (btn.dataset.view === "settings") loadLibraries(); }) ); // ---- libraries ---- async function loadLibraries() { libraries = await api("/api/libraries"); const tbody = $("#lib-table tbody"); tbody.innerHTML = libraries .map( (l) => ` ${esc(l.name)}${esc(l.media_type)}${esc(l.root_path)} ${esc(l.folder_template)}${esc(l.file_template)} ` ) .join(""); tbody.querySelectorAll("[data-del-lib]").forEach((b) => b.addEventListener("click", async () => { if (!confirm("Library löschen?")) return; try { await api("/api/libraries/" + b.dataset.delLib, { method: "DELETE" }); loadLibraries(); } catch (e) { toast(e.message, true); } }) ); const filter = $("#missing-filter-library"); filter.innerHTML = '' + libraries.map((l) => ``).join(""); } $("#lib-form").addEventListener("submit", async (e) => { e.preventDefault(); const data = Object.fromEntries(new FormData(e.target)); if (!data.folder_template) delete data.folder_template; if (!data.file_template) delete data.file_template; try { await api("/api/libraries", { method: "POST", body: JSON.stringify(data) }); e.target.reset(); loadLibraries(); toast("Library angelegt"); } catch (err) { toast(err.message, true); } }); // path autocompletion for the library form { const pathInput = document.querySelector('#lib-form [name=root_path]'); const datalist = $("#path-suggestions"); let pathTimer; pathInput.addEventListener("input", () => { clearTimeout(pathTimer); const value = pathInput.value; if (!value.startsWith("/")) return; pathTimer = setTimeout(async () => { try { const res = await api("/api/fs/browse?path=" + encodeURIComponent(value)); datalist.innerHTML = res.dirs.map((d) => ``) .join(""); } // ---- search & request ---- $("#search-form").addEventListener("submit", async (e) => { e.preventDefault(); const type = $("#search-type").value; const q = $("#search-q").value; const box = $("#search-results"); box.innerHTML = "

Suche läuft…

"; try { const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`); if (!results.length) { box.innerHTML = "

Keine Treffer.

"; return; } const opts = libOptions(type); box.innerHTML = results .map( (r, i) => `
${r.cover_url ? `` : '
?
'}
${esc(r.title)} ${esc(r.authors)} ${r.year ?? ""} ${r.external_id ? "· " + esc(r.external_id) : ""} ${type === "comic" ? `` : ""}
` ) .join(""); box.querySelectorAll("[data-req]").forEach((btn) => btn.addEventListener("click", async () => { const i = btn.dataset.req; const r = results[i]; const libId = box.querySelector(`[data-lib="${i}"]`).value; if (!libId) { toast("Erst eine Library für diesen Typ anlegen (Tab Libraries)", true); return; } const volInput = box.querySelector(`[data-vol="${i}"]`); try { await api("/api/requests", { method: "POST", body: JSON.stringify({ library_id: parseInt(libId), title: r.title, authors: r.authors, external_id: r.external_id, year: r.year, series: r.series, cover_url: r.cover_url, volume: volInput && volInput.value ? parseInt(volInput.value) : null, }), }); btn.textContent = "✓ Angefragt"; btn.disabled = true; } catch (err) { toast(err.message, true); } }) ); } catch (err) { box.innerHTML = ""; toast(err.message, true); } }); // ---- manual request ---- $("#manual-btn").addEventListener("click", () => { const dlg = $("#manual-dialog"); const typeSel = dlg.querySelector("[name=media_type]"); const updateLibs = () => { $("#manual-library").innerHTML = libOptions(typeSel.value) || ""; }; typeSel.onchange = updateLibs; updateLibs(); dlg.showModal(); }); $("#manual-form").addEventListener("submit", async (e) => { if (e.submitter && e.submitter.value === "cancel") return; const data = Object.fromEntries(new FormData(e.target)); if (!data.library_id) { toast("Keine Library gewählt", true); return; } try { if (data.volume_to) { if (!data.volume) { toast("Für Bulk bitte Start-Band angeben", true); return; } const created = await api("/api/requests/bulk", { method: "POST", body: JSON.stringify({ library_id: parseInt(data.library_id), series: data.series || data.title, authors: data.authors, volume_from: parseInt(data.volume), volume_to: parseInt(data.volume_to), year: data.year ? parseInt(data.year) : null, }), }); toast(`${created.length} Anfragen angelegt`); } else { await api("/api/requests", { method: "POST", body: JSON.stringify({ library_id: parseInt(data.library_id), title: data.title, authors: data.authors, series: data.series, external_id: data.external_id, year: data.year ? parseInt(data.year) : null, volume: data.volume ? parseInt(data.volume) : null, }), }); toast("Anfrage angelegt"); } e.target.reset(); } catch (err) { toast(err.message, true); } }); // ---- missing / imported lists ---- async function loadRequests(status) { const params = new URLSearchParams({ status }); if (status === "missing") { const t = $("#missing-filter-type").value; const l = $("#missing-filter-library").value; if (t) params.set("media_type", t); if (l) params.set("library_id", l); } const reqs = await api("/api/requests?" + params); const box = status === "missing" ? $("#missing-list") : $("#imported-list"); if (!reqs.length) { box.innerHTML = "

Nichts hier.

"; return; } box.innerHTML = reqs .map( (r) => `
${r.cover_url ? `` : '
?
'}
${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""} ${esc(r.authors)} ${esc(r.media_type)} → ${esc(r.library_name)} ${status === "imported" ? `${esc(r.imported_path)}` : ""}
` ) .join(""); box.querySelectorAll("[data-del-req]").forEach((b) => b.addEventListener("click", async () => { if (!confirm("Anfrage entfernen?")) return; await api("/api/requests/" + b.dataset.delReq, { method: "DELETE" }); loadRequests(status); }) ); } $("#missing-filter-type").addEventListener("change", () => loadRequests("missing")); $("#missing-filter-library").addEventListener("change", () => loadRequests("missing")); // ---- import ---- let scanItems = []; let missingReqs = []; $("#scan-btn").addEventListener("click", async () => { $("#scan-info").textContent = "Scanne…"; try { const split = $("#split-dirs").checked; const [scan, reqs] = await Promise.all([ api("/api/import/scan?split_dirs=" + split), api("/api/requests?status=missing"), ]); scanItems = scan.items; missingReqs = reqs; $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`; renderImportTable(); } catch (err) { $("#scan-info").textContent = ""; toast(err.message, true); } }); function renderImportTable() { const table = $("#import-table"); const tbody = table.querySelector("tbody"); if (!scanItems.length) { table.hidden = true; $("#import-btn").hidden = true; return; } tbody.innerHTML = scanItems .map((item, i) => { const opts = missingReqs .filter((r) => r.media_type === item.media_type) .map( (r) => `` ) .join(""); return ` ${esc(item.name)}${item.is_dir ? " 📁" : ""} ${esc(item.media_type)} ${item.suggested_request_id ? item.score : "—"} `; }) .join(""); table.hidden = false; $("#import-btn").hidden = false; tbody.querySelectorAll("[data-quick]").forEach((b) => b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick))) ); } // ---- quick request from a scanned file ---- let quickItemIndex = null; function cleanFileName(name) { return name .replace(/[._\-\[\]()]+/g, " ") .replace(/\b(mp3|m4b|flac|epub|pdf|cbz|cbr|retail|unabridged|kompl.*)\b/gi, " ") .replace(/\s+/g, " ") .trim(); } function openQuickDialog(i) { quickItemIndex = i; const item = scanItems[i]; $("#quick-file").textContent = item.name; $("#quick-q").value = cleanFileName(item.name); $("#quick-library").innerHTML = libOptions(item.media_type) || ""; $("#quick-results").innerHTML = ""; $("#quick-dialog").showModal(); runQuickSearch(); } async function runQuickSearch() { const item = scanItems[quickItemIndex]; const box = $("#quick-results"); box.innerHTML = "

Suche läuft…

"; try { const results = await api( `/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` ); if (!results.length) { box.innerHTML = "

Keine Treffer — Suchbegriff anpassen.

"; return; } box.innerHTML = results .map( (r, j) => `
${r.cover_url ? `` : '
?
'}
${esc(r.title)} ${esc(r.authors)} ${r.year ?? ""}
` ) .join(""); box.querySelectorAll("[data-pick]").forEach((btn) => btn.addEventListener("click", () => pickQuickResult(results[btn.dataset.pick])) ); } catch (err) { box.innerHTML = ""; toast(err.message, true); } } $("#quick-search-form").addEventListener("submit", (e) => { e.preventDefault(); runQuickSearch(); }); $("#quick-close").addEventListener("click", () => $("#quick-dialog").close()); async function pickQuickResult(r) { const libId = $("#quick-library").value; if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; } const i = quickItemIndex; try { const req = await api("/api/requests", { method: "POST", body: JSON.stringify({ library_id: parseInt(libId), title: r.title, authors: r.authors, external_id: r.external_id, year: r.year, series: r.series, cover_url: r.cover_url, }), }); missingReqs.push(req); // add the new request to every dropdown of the same media type, then link this row const item = scanItems[i]; scanItems.forEach((it, j) => { if (it.media_type !== item.media_type) return; const sel = document.querySelector(`[data-select="${j}"]`); const opt = document.createElement("option"); opt.value = req.id; opt.textContent = `${req.title} — ${req.authors} (${req.library_name})`; sel.appendChild(opt); }); document.querySelector(`[data-select="${i}"]`).value = req.id; document.querySelector(`[data-check="${i}"]`).checked = true; $("#quick-dialog").close(); toast(`„${req.title}" angefragt und verbunden`); } catch (err) { toast(err.message, true); } } $("#import-btn").addEventListener("click", async () => { const items = []; scanItems.forEach((item, i) => { const checked = document.querySelector(`[data-check="${i}"]`).checked; const reqId = document.querySelector(`[data-select="${i}"]`).value; if (checked && reqId) { items.push({ path: item.path, is_dir: item.is_dir, files: item.files, request_id: parseInt(reqId) }); } }); if (!items.length) { toast("Nichts ausgewählt", true); return; } try { const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items }) }); $("#import-results").innerHTML = res.results .map((r) => r.ok ? `

✓ ${esc(r.path)} → ${esc(r.dest)}

` : `

✗ ${esc(r.path)}: ${esc(r.error)}

` ) .join(""); $("#scan-btn").click(); } catch (err) { toast(err.message, true); } }); // ---- init ---- loadLibraries();