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; } // last used library per media type (per-browser convenience) function getLastLib(type) { try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; } } function setLastLib(type, id) { try { localStorage.setItem("wordarr.lastLib." + type, String(id)); } catch {} } function libOptions(mediaType) { const last = getLastLib(mediaType); return libraries .filter((l) => l.media_type === mediaType) .map((l) => ``) .join(""); } // ---- missing badge ---- async function refreshMissingBadge() { try { const reqs = await api("/api/requests?status=missing"); const badge = $("#missing-badge"); badge.textContent = reqs.length; badge.hidden = reqs.length === 0; } catch {} } // ---- 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) => `"; }; 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, narrator: data.narrator || "", 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, narrator: data.narrator || "", external_id: data.external_id, year: data.year ? parseInt(data.year) : null, volume: data.volume ? parseInt(data.volume) : null, }), }); toast("Anfrage angelegt"); } setLastLib(data.media_type, data.library_id); e.target.reset(); refreshMissingBadge(); } catch (err) { toast(err.message, true); } }); // ---- missing / imported lists ---- let currentRequests = { missing: [], imported: [] }; function requestMatchesFilter(r, text) { if (!text) return true; const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase(); return hay.includes(text.toLowerCase()); } 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); } currentRequests[status] = await api("/api/requests?" + params); renderRequests(status); if (status === "missing") refreshMissingBadge(); } function renderRequests(status) { const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value; const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text)); const box = status === "missing" ? $("#missing-list") : $("#imported-list"); if (!reqs.length) { box.innerHTML = "

Nichts hier.

"; if (status === "missing") $("#missing-bulk-delete").hidden = true; return; } box.innerHTML = reqs .map( (r) => `
${r.cover_url ? `` : '
?
'}
${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""} ${esc(r.authors)} ${r.narrator ? `🎙 ${esc(r.narrator)}` : ""} ${esc(r.media_type)} → ${esc(r.library_name)} ${status === "imported" ? `${esc(r.imported_path)}` : ""}
${status === "missing" ? `` : ""}
` ) .join(""); box.querySelectorAll("[data-detail]").forEach((card) => card.addEventListener("click", (ev) => { if (ev.target.closest("input,button,select,a")) return; openDetail(parseInt(card.dataset.detail), status); }) ); if (status === "missing") { const updateBulkBtn = () => { const n = box.querySelectorAll("[data-sel-req]:checked").length; const btn = $("#missing-bulk-delete"); btn.hidden = n === 0; btn.textContent = `Ausgewählte entfernen (${n})`; }; box.querySelectorAll("[data-sel-req]").forEach((c) => c.addEventListener("change", updateBulkBtn) ); updateBulkBtn(); } } $("#missing-filter-type").addEventListener("change", () => loadRequests("missing")); $("#missing-filter-library").addEventListener("change", () => loadRequests("missing")); $("#missing-search").addEventListener("input", () => renderRequests("missing")); $("#imported-search").addEventListener("input", () => renderRequests("imported")); $("#missing-bulk-delete").addEventListener("click", async () => { const ids = [...$("#missing-list").querySelectorAll("[data-sel-req]:checked")] .map((c) => c.dataset.selReq); if (!ids.length) return; if (!confirm(`${ids.length} Anfrage(n) entfernen?`)) return; for (const id of ids) { try { await api("/api/requests/" + id, { method: "DELETE" }); } catch (err) { toast(err.message, true); } } toast(`${ids.length} Anfrage(n) entfernt`); loadRequests("missing"); }); // ---- request detail dialog ---- let detailRequest = null; function openDetail(id, status) { const r = currentRequests[status].find((x) => x.id === id); if (!r) return; detailRequest = r; const form = $("#detail-form"); form.title.value = r.title; form.authors.value = r.authors || ""; form.narrator.value = r.narrator || ""; form.series.value = r.series || ""; form.volume.value = r.volume ?? ""; form.year.value = r.year ?? ""; form.external_id.value = r.external_id || ""; $("#detail-library").innerHTML = libraries .filter((l) => l.media_type === r.media_type) .map((l) => ``) .join(""); $("#detail-title").textContent = r.title; $("#detail-status").textContent = `${r.media_type} · ${r.status === "missing" ? "fehlt noch" : "importiert"}`; $("#detail-path").textContent = r.imported_path || ""; const img = $("#detail-cover"); img.hidden = !r.cover_url; $("#detail-nocover").hidden = !!r.cover_url; if (r.cover_url) img.src = r.cover_url; $("#detail-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook"); $("#detail-dialog").showModal(); } async function saveDetail(form) { await api("/api/requests/" + detailRequest.id, { method: "PUT", body: JSON.stringify({ library_id: parseInt(form.library_id.value), title: form.title.value, authors: form.authors.value, narrator: form.narrator.value, series: form.series.value, volume: form.volume.value ? parseInt(form.volume.value) : null, year: form.year.value ? parseInt(form.year.value) : null, external_id: form.external_id.value, cover_url: detailRequest.cover_url || "", }), }); } $("#detail-retag").addEventListener("click", async () => { if (!detailRequest) return; const btn = $("#detail-retag"); btn.disabled = true; btn.innerHTML = ' Tagge…'; try { await saveDetail($("#detail-form")); const res = await api(`/api/requests/${detailRequest.id}/retag`, { method: "POST" }); toast(`${res.files} Datei(en) neu getaggt`); $("#detail-dialog").close(); loadRequests(detailRequest.status); } catch (err) { toast(err.message, true); } finally { btn.disabled = false; btn.textContent = "Speichern & neu taggen"; } }); $("#detail-form").addEventListener("submit", async (e) => { if (e.submitter && e.submitter.value === "cancel") return; if (!detailRequest) return; try { await saveDetail(e.target); toast("Anfrage gespeichert"); loadRequests(detailRequest.status); } catch (err) { toast(err.message, true); } }); // ---- import ---- let scanItems = []; let missingReqs = []; let viewIdx = []; // indices into scanItems after filter/sort let importPage = 0; const IMPORT_PAGE_SIZE = 25; $("#scan-btn").addEventListener("click", async () => { const scanBtn = $("#scan-btn"); scanBtn.disabled = true; $("#scan-info").innerHTML = ' 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; // selection state lives here, not in the DOM, so it survives paging/filtering scanItems.forEach((item) => { item.request_id = item.suggested_request_id; item.checked = !!item.suggested_request_id; }); missingReqs = reqs; importPage = 0; $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`; $("#import-toolbar").hidden = scanItems.length === 0; renderImportTable(); } catch (err) { $("#scan-info").textContent = ""; toast(err.message, true); } finally { scanBtn.disabled = false; } }); function applyImportView() { const text = $("#import-filter-text").value.toLowerCase(); const mode = $("#import-filter-mode").value; const sort = $("#import-sort").value; viewIdx = scanItems .map((_, i) => i) .filter((i) => { const item = scanItems[i]; if (text && !(`${item.name} ${item.rel_dir || ""}`.toLowerCase().includes(text))) return false; if (mode === "suggested" && !item.suggested_request_id) return false; if (mode === "unassigned" && item.request_id) return false; return true; }); if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score); if (sort === "name") viewIdx.sort((a, b) => scanItems[a].name.localeCompare(scanItems[b].name)); } function scoreCell(item) { if (!item.suggested_request_id) return '—'; const cls = item.score >= 80 ? "score-hi" : item.score >= 50 ? "score-mid" : "score-lo"; return `${item.score}`; } function renderImportTable() { applyImportView(); const table = $("#import-table"); const tbody = table.querySelector("tbody"); if (!viewIdx.length) { table.hidden = true; $("#import-btn").hidden = scanItems.length === 0; $("#import-pager").hidden = true; tbody.innerHTML = ""; if (scanItems.length) { $("#import-page-info").textContent = ""; } return; } const pages = Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE); importPage = Math.min(importPage, pages - 1); const start = importPage * IMPORT_PAGE_SIZE; const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE); tbody.innerHTML = pageIdx .map((i) => { const item = scanItems[i]; const opts = missingReqs .filter((r) => r.media_type === item.media_type) .map( (r) => `` ) .join(""); return ` ${esc(item.name)}${item.is_dir ? " 📁" : ""} ${item.rel_dir ? `
${esc(item.rel_dir)}/
` : ""} ${esc(item.media_type)} ${scoreCell(item)} `; }) .join(""); table.hidden = false; $("#import-btn").hidden = false; tbody.querySelectorAll("[data-quick]").forEach((b) => b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick))) ); tbody.querySelectorAll("[data-check]").forEach((c) => c.addEventListener("change", () => { scanItems[c.dataset.check].checked = c.checked; updateImportPageInfo(); }) ); tbody.querySelectorAll("[data-select]").forEach((s) => s.addEventListener("change", () => { const item = scanItems[s.dataset.select]; item.request_id = s.value ? parseInt(s.value) : null; item.checked = !!item.request_id; const check = tbody.querySelector(`[data-check="${s.dataset.select}"]`); if (check) check.checked = item.checked; updateImportPageInfo(); }) ); $("#import-pager").hidden = false; $("#import-prev").disabled = importPage === 0; $("#import-next").disabled = importPage >= pages - 1; updateImportPageInfo(pages); } function updateImportPageInfo(pages) { pages = pages ?? Math.max(1, Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE)); const selected = scanItems.filter((it) => it.checked && it.request_id).length; const filtered = viewIdx.length !== scanItems.length ? ` (gefiltert: ${viewIdx.length})` : ""; $("#import-page-info").textContent = `Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge${filtered} · ${selected} ausgewählt`; } $("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); }); $("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); }); $("#import-filter-text").addEventListener("input", () => { importPage = 0; renderImportTable(); }); $("#import-filter-mode").addEventListener("change", () => { importPage = 0; renderImportTable(); }); $("#import-sort").addEventListener("change", () => { importPage = 0; renderImportTable(); }); $("#import-select-suggested").addEventListener("click", () => { applyImportView(); viewIdx.forEach((i) => { const item = scanItems[i]; if (item.suggested_request_id) { item.request_id = item.request_id || item.suggested_request_id; item.checked = true; } }); renderImportTable(); }); $("#import-deselect-all").addEventListener("click", () => { applyImportView(); viewIdx.forEach((i) => (scanItems[i].checked = false)); renderImportTable(); }); // ---- 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.rel_dir ? item.rel_dir + "/" : "") + 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 = `

${EMPTY_HINTS[item.media_type]}

`; return; } box.innerHTML = results .map( (r, j) => `
${r.cover_url ? `` : '
?
'}
${esc(r.title)} ${esc(r.authors)} ${r.narrator ? `🎙 ${esc(r.narrator)}` : ""} ${r.series && r.media_type ? `📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}` : ""} ${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, narrator: r.narrator || "", external_id: r.external_id, volume: r.volume ?? null, year: r.year, series: r.series, cover_url: r.cover_url, }), }); setLastLib(scanItems[i].media_type, libId); missingReqs.push(req); scanItems[i].request_id = req.id; scanItems[i].checked = true; renderImportTable(); refreshMissingBadge(); $("#quick-dialog").close(); toast(`„${req.title}" angefragt und verbunden`); } catch (err) { toast(err.message, true); } } // ---- import execution (batched, with progress) ---- const IMPORT_BATCH_SIZE = 20; $("#import-btn").addEventListener("click", async () => { const items = scanItems .filter((item) => item.checked && item.request_id) .map((item) => ({ path: item.path, is_dir: item.is_dir, files: item.files, request_id: item.request_id, })); if (!items.length) { toast("Nichts ausgewählt", true); return; } const btn = $("#import-btn"); btn.disabled = true; const allResults = []; try { for (let done = 0; done < items.length; done += IMPORT_BATCH_SIZE) { btn.innerHTML = ` Importiere… ${Math.min(done + IMPORT_BATCH_SIZE, items.length)}/${items.length}`; const batch = items.slice(done, done + IMPORT_BATCH_SIZE); const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items: batch }) }); allResults.push(...res.results); } const failed = allResults.filter((r) => !r.ok); $("#import-summary").innerHTML = `

` + `${allResults.length - failed.length} importiert` + (failed.length ? `, ${failed.length} Fehler` : "") + `

`; $("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)] .map((r) => r.ok ? `

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

` : `

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

` ) .join(""); toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", failed.length > 0); refreshMissingBadge(); $("#scan-btn").click(); } catch (err) { toast(err.message, true); } finally { btn.disabled = false; btn.textContent = "Ausgewählte importieren"; } }); // ---- init ---- loadLibraries(); refreshMissingBadge();