const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); let libraries = []; const languageName = (code) => (code ? t("lang." + code) : ""); const SOURCE_NAMES = { musicbrainz: "MusicBrainz", hardcover: "Hardcover", googlebooks: "Google Books", openlibrary: "Open Library", }; 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 {} const err = new Error(msg); err.status = resp.status; throw err; } return resp.json(); } // on a duplicate warning (409) ask before retrying async function createRequest(body) { try { return await api("/api/requests", { method: "POST", body: JSON.stringify(body) }); } catch (err) { if (err.status === 409 && confirm(err.message + "\n" + t("detail.duplicateConfirm"))) { return api("/api/requests?allow_duplicate=true", { method: "POST", body: JSON.stringify(body), }); } throw err; } } function toast(msg, isError = false) { const t = $("#toast"); t.textContent = msg; t.className = isError ? "error" : ""; t.hidden = false; setTimeout(() => (t.hidden = true), 4000); } // quotes included: the result also lands inside attributes (value="${esc(...)}") const ESC_CHARS = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ESC_CHARS[c]); } // last used library per media type 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 noLibraryOption() { return ``; } function decimalMark() { return lang === "de" ? "," : "."; } 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[data-view]").forEach((btn) => btn.addEventListener("click", () => { $$("nav button[data-view]").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)} ${l.language ? esc(languageName(l.language)) : "โ€”"} ` ) .join(""); tbody.querySelectorAll("[data-edit-lib]").forEach((b) => b.addEventListener("click", () => startLibraryEdit(parseInt(b.dataset.editLib))) ); tbody.querySelectorAll("[data-del-lib]").forEach((b) => b.addEventListener("click", async () => { if (!confirm(t("lib.confirmDelete"))) 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(""); } function startLibraryEdit(id) { const lib = libraries.find((l) => l.id === id); if (!lib) return; const form = $("#lib-form"); form.name.value = lib.name; form.media_type.value = lib.media_type; form.root_path.value = lib.root_path; form.folder_template.value = lib.folder_template; form.file_template.value = lib.file_template; form.language.value = lib.language || ""; form.dataset.editId = lib.id; $("#lib-form-title").textContent = t("lib.edit", { name: lib.name }); $("#lib-form-submit").textContent = t("common.save"); $("#lib-form-cancel").hidden = false; form.scrollIntoView({ behavior: "smooth" }); } function endLibraryEdit() { const form = $("#lib-form"); form.reset(); delete form.dataset.editId; $("#lib-form-title").textContent = t("lib.new"); $("#lib-form-submit").textContent = t("common.create"); $("#lib-form-cancel").hidden = true; } $("#lib-form-cancel").addEventListener("click", endLibraryEdit); $("#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; const editId = e.target.dataset.editId; try { if (editId) { await api("/api/libraries/" + editId, { method: "PUT", body: JSON.stringify(data) }); toast(t("lib.saved")); } else { await api("/api/libraries", { method: "POST", body: JSON.stringify(data) }); toast(t("lib.created")); } endLibraryEdit(); loadLibraries(); } 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(""); $("#detail-title").textContent = r.title; $("#detail-status").textContent = `${r.media_type} ยท ${t(r.status === "missing" ? "detail.statusMissing" : "detail.statusImported")}`; $("#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-relocate").hidden = r.status !== "imported"; $("#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 || "", }), }); } // re-applies the library's scheme to files already on disk $("#detail-relocate").addEventListener("click", async () => { if (!detailRequest) return; const btn = $("#detail-relocate"); btn.disabled = true; btn.innerHTML = ` ${t("detail.relocating")}`; try { await saveDetail($("#detail-form")); const res = await api(`/api/requests/${detailRequest.id}/relocate`, { method: "POST" }); toast(res.moved ? t("detail.relocated", { dest: res.dest }) : t("detail.alreadyPlaced")); $("#detail-dialog").close(); loadRequests(detailRequest.status); } catch (err) { toast(err.message, true); } finally { btn.disabled = false; btn.textContent = t("detail.relocate"); } }); $("#detail-retag").addEventListener("click", async () => { if (!detailRequest) return; const btn = $("#detail-retag"); btn.disabled = true; btn.innerHTML = ` ${t("detail.retagging")}`; try { await saveDetail($("#detail-form")); const res = await api(`/api/requests/${detailRequest.id}/retag`, { method: "POST" }); toast(t("detail.retagged", { n: res.files })); $("#detail-dialog").close(); loadRequests(detailRequest.status); } catch (err) { toast(err.message, true); } finally { btn.disabled = false; btn.textContent = t("detail.retag"); } }); $("#detail-form").addEventListener("submit", async (e) => { if (e.submitter && e.submitter.value === "cancel") return; if (!detailRequest) return; try { await saveDetail(e.target); toast(t("detail.saved")); loadRequests(detailRequest.status); } catch (err) { toast(err.message, true); } }); // ---- import ---- let scanItems = []; let missingReqs = []; let importedReqs = []; let skippedConflicts = 0; let orphanedIds = new Set(); // imported, but their files are gone from the library 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 = ` ${t("import.scanning")}`; try { const split = $("#split-dirs").checked; const [scan, reqs, done] = await Promise.all([ api("/api/import/scan?split_dirs=" + split), api("/api/requests?status=missing"), api("/api/requests?status=imported"), ]); clearTimeout(cleanupTimer); scanItems = scan.items; // selection lives here, not in the DOM, so it survives paging scanItems.forEach((item) => { item.request_id = item.suggested_request_id; item.checked = !!item.suggested_request_id; }); orphanedIds = new Set(scan.orphaned_request_ids || []); // an imported title whose files vanished counts as open again missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))]; importedReqs = done.filter((r) => !orphanedIds.has(r.id)); importPage = 0; $("#scan-info").textContent = t("import.candidates", { n: scan.items.length, dir: scan.download_dir }); $("#import-toolbar").hidden = scanItems.length === 0; formatOptions(); 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 format = $("#import-filter-format").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; if (mode === "conflict" && !item.conflict) return false; if (format && !(item.formats || []).includes(format)) 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)); } // the split of a folder happens client side, so the extension comes off the name function extOf(path) { const ext = (path.split("/").pop().match(/\.([^.]+)$/) || [])[1]; return ext ? [ext.toLowerCase()] : []; } function humanSize(bytes) { if (!bytes) return ""; const units = ["B", "KB", "MB", "GB"]; let n = bytes, u = 0; while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; } return `${n.toFixed(n < 10 && u > 0 ? 1 : 0).replace(".", decimalMark())} ${units[u]}`; } // a Calibre export ships one book in a dozen formats, so the row has to say // which one it is before anything can be picked function formatTag(item) { const formats = item.formats || []; if (!formats.length) return ""; const size = humanSize(item.size); return `${esc(formats.join(", "))}${size ? " ยท " + size : ""}`; } function formatOptions() { const all = [...new Set(scanItems.flatMap((it) => it.formats || []))].sort(); const current = $("#import-filter-format").value; $("#import-filter-format").innerHTML = `` + all.map((f) => ``).join(""); } 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 option = (r, suffix = "") => ``; const opts = missingReqs .filter((r) => r.media_type === item.media_type) .map((r) => option(r, orphanedIds.has(r.id) ? " โ†บ" : "")) .join(""); // imported audiobooks can take further parts (a late CD) const appendOpts = importedReqs .filter((r) => r.media_type === item.media_type && r.imported_path) .map((r) => option(r, " โ†ฉ๏ธŽ")) .join(""); return ` ${esc(item.name)}${item.is_dir ? " ๐Ÿ“" : ""} ${formatTag(item)} ${item.parts ? `${t("import.parts", { parts: item.parts.length, files: item.files.length })}` : ""} ${!item.parts && item.maybe_separate ? `${t("import.maybeSeparate", { n: item.files.length })}` : ""} ${item.rel_dir ? `
${esc(item.rel_dir)}/
` : ""} ${esc(item.media_type)} ${item.parts || item.maybe_separate ? `` : ""} ${libraryCell(item, i)} ${item.importState ? importStateCell(item) : item.conflict ? `${t("import.conflict")}` : scoreCell(item)} `; }) .join(""); table.hidden = false; $("#import-btn").hidden = false; const showsState = scanItems.some((it) => it.importState); const head = $("#import-score-head"); head.dataset.i18n = showsState ? "common.status" : "common.score"; head.textContent = t(head.dataset.i18n); tbody.querySelectorAll("[data-quick]").forEach((b) => b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick))) ); tbody.querySelectorAll("[data-lib-for]").forEach((sel) => sel.addEventListener("change", async () => { const item = scanItems[sel.dataset.libFor]; const req = requestById(item.request_id); if (!req) return; const previous = req.library_id; req.library_id = parseInt(sel.value); try { const saved = await api("/api/requests/" + req.id, { method: "PUT", body: JSON.stringify({ library_id: req.library_id, title: req.title, authors: req.authors, narrator: req.narrator || "", series: req.series, volume: req.volume, year: req.year, external_id: req.external_id, cover_url: req.cover_url, }), }); Object.assign(req, saved); toast(t("import.movedToLibrary", { title: req.title, library: saved.library_name })); } catch (err) { req.library_id = previous; sel.value = String(previous); toast(err.message, true); } renderImportTable(); }) ); tbody.querySelectorAll("[data-split]").forEach((b) => b.addEventListener("click", () => splitItem(parseInt(b.dataset.split))) ); 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); } // target library of the assigned request, easy to miss inside the dialog function libraryCell(item, i) { const req = requestById(item.request_id); if (!req) return 'โ€”'; const opts = libraries .filter((l) => l.media_type === req.media_type) .map((l) => ``) .join(""); return ``; } function requestById(id) { if (!id) return null; return missingReqs.find((r) => r.id === id) || importedReqs.find((r) => r.id === id) || null; } function importStateCell(item) { if (item.importState === "running") { return ` ${t("import.running")}`; } if (item.importState === "done") return `${t("import.done")}`; return `${t("import.failed")}`; } 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 ? t("import.filtered", { n: viewIdx.length }) : ""; $("#import-page-info").textContent = t("import.pageInfo", { page: importPage + 1, pages, total: scanItems.length, filtered, selected, }); } $("#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-filter-format").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-select-page").addEventListener("click", () => { applyImportView(); const start = importPage * IMPORT_PAGE_SIZE; const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE); selectIndices(pageIdx, t("import.thisPage")); }); $("#import-select-all").addEventListener("click", () => { applyImportView(); selectIndices(viewIdx, t("import.inTotal")); }); // entries without a suggestion are included, "requests from folder names" needs them function selectIndices(indices, what) { scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add" indices.forEach((i) => { const item = scanItems[i]; const req = item.request_id || item.suggested_request_id; if (req) item.request_id = req; item.checked = true; }); renderImportTable(); const withReq = indices.filter((i) => scanItems[i].request_id).length; toast( t("import.selectedN", { n: indices.length, what }) + (withReq < indices.length ? t("import.selectedWithout", { n: indices.length - withReq }) : "") ); } $("#import-deselect-all").addEventListener("click", () => { applyImportView(); viewIdx.forEach((i) => (scanItems[i].checked = false)); renderImportTable(); }); // ---- create requests straight from folder names ---- // for series Audible only knows in part, the folder names carry number and title let namesTargets = []; function nameToRequest(item) { const { number, rest } = splitEpisodeNumber(item.name); return { title: cleanFileName(rest) || item.name, volume: number }; } $("#import-from-names").addEventListener("click", () => { namesTargets = scanItems.filter((it) => it.checked && !it.request_id); if (!namesTargets.length) { toast(t("names.noneWithout"), true); return; } const type = namesTargets[0].media_type; namesTargets = namesTargets.filter((it) => it.media_type === type); $("#names-intro").textContent = t("names.intro", { n: namesTargets.length, type }); $("#names-series").value = lastSeries; $("#names-authors").value = ""; $("#names-library").innerHTML = libOptions(type) || noLibraryOption(); renderNamesPreview(); $("#names-dialog").showModal(); }); function renderNamesPreview() { const shown = namesTargets.slice(0, 12); $("#names-preview").innerHTML = shown .map((it) => { const { title, volume } = nameToRequest(it); return ``; }) .join("") + (namesTargets.length > shown.length ? `
${esc(t("names.more", { n: namesTargets.length - shown.length }))}
` : ""); } $("#names-series").addEventListener("input", renderNamesPreview); $("#names-cancel").addEventListener("click", () => $("#names-dialog").close()); $("#names-submit").addEventListener("click", async () => { const libId = $("#names-library").value; if (!libId) { toast(t("common.needLibraryFirst"), true); return; } const series = (lastSeries = $("#names-series").value.trim()); const authors = $("#names-authors").value.trim(); const btn = $("#names-submit"); btn.disabled = true; btn.innerHTML = ` ${t("common.creating")}`; try { const items = namesTargets.map((it) => ({ ...nameToRequest(it), series, authors })); const res = await api("/api/requests/bulk-items", { method: "POST", body: JSON.stringify({ library_id: parseInt(libId), items }), }); // reload so duplicates can be linked to the existing request missingReqs = await api("/api/requests?status=missing"); const key = (t, v) => `${(t || "").trim().toLowerCase()}|${v ?? ""}`; const byKey = new Map(missingReqs.map((r) => [key(r.title, r.volume), r])); let linked = 0; namesTargets.forEach((it) => { const { title, volume } = nameToRequest(it); const req = byKey.get(key(title, volume)); if (!req) return; it.request_id = req.id; it.checked = true; linked += 1; }); setLastLib(namesTargets[0].media_type, libId); $("#names-dialog").close(); renderImportTable(); refreshMissingBadge(); toast(t("names.result", { created: res.created.length, skipped: res.skipped ? t("names.existed", { n: res.skipped }) : "", linked, })); } catch (err) { toast(err.message, true); } finally { btn.disabled = false; btn.textContent = t("common.createAndLink"); } }); // ---- multi-part episodes: import several entries as one audiobook ---- // e.g. "100 - Toteninsel Teil 1/2/3", one title for Audible function commonPrefix(names) { let prefix = names[0]; for (const n of names.slice(1)) { let i = 0; while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++; prefix = prefix.slice(0, i); } // drop a dangling part marker return prefix.replace(/[\s._\-โ€“โ€”:]*(?:teil|part|cd|disc|folge)?[\s._\-โ€“โ€”:]*$/i, "").trim(); } $("#import-merge").addEventListener("click", () => { const chosen = scanItems.filter((it) => it.checked); if (chosen.length < 2) { toast(t("import.needTwo"), true); return; } if (new Set(chosen.map((it) => it.media_type)).size > 1) { toast(t("import.sameTypeOnly"), true); return; } const parents = new Set(chosen.map((it) => it.rel_dir)); if (parents.size > 1) { toast(t("import.differentFolders"), true); return; } // "A - Sphinx", "B - Volk" share no prefix, then the folder carries the title const parentName = (chosen[0].rel_dir || "").split("/").filter(Boolean).pop(); const name = commonPrefix(chosen.map((it) => it.name)) || parentName || chosen[0].name; const merged = { path: chosen[0].dir || chosen[0].path, dir: chosen[0].dir, name, rel_dir: chosen[0].rel_dir, media_type: chosen[0].media_type, is_dir: true, files: chosen.flatMap((it) => it.files), formats: [...new Set(chosen.flatMap((it) => it.formats || []))].sort(), size: chosen.reduce((n, it) => n + (it.size || 0), 0), checked: true, request_id: chosen.find((it) => it.request_id)?.request_id ?? null, suggested_request_id: chosen.find((it) => it.suggested_request_id)?.suggested_request_id ?? null, score: Math.max(...chosen.map((it) => it.score ?? 0)), parts: chosen, }; const first = scanItems.indexOf(chosen[0]); scanItems = scanItems.filter((it) => !chosen.includes(it)); scanItems.splice(first, 0, merged); toast(t("import.mergedN", { n: chosen.length, name, files: merged.files.length })); renderImportTable(); }); function splitItem(i) { const item = scanItems[i]; if (item.parts) { // undo a merge scanItems.splice(i, 1, ...item.parts); } else if (item.is_dir && item.files.length > 1) { // one book per file: every file becomes its own entry const parts = item.files.map((f) => ({ path: f, name: f.split("/").pop().replace(/\.[^.]+$/, ""), rel_dir: item.rel_dir ? `${item.rel_dir}/${item.name}` : item.name, media_type: item.media_type, is_dir: false, files: [f], formats: extOf(f), checked: false, request_id: null, suggested_request_id: null, score: null, })); scanItems.splice(i, 1, ...parts); toast(t("import.splitN", { name: item.name, n: parts.length })); } else { return; } renderImportTable(); } // ---- quick request from a scanned file ---- let quickItemIndex = null; let quickEpisode = null; let quickPicked = null; // metadata hit the manual fields were filled from let quickAutoSearch = false; // the search fired on open, not by the user let lastSeries = ""; // "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest. // A bare number without separator or leading zero stays part of the title. const EPISODE_PATTERNS = [ /^\s*(?:folge|teil|band|nr\.?)\s*(\d{1,4})\s*(?:[-โ€“โ€”._:]+\s*|\s+)/i, // "Folge 124: Titel" /^\s*[\[(](\d{1,4})[\])]\s*(?:[-โ€“โ€”._:]+\s*|\s+)/, // "[003] Titel" /^\s*(0\d{1,3})\s*(?:[-โ€“โ€”._:]+\s*|\s+)/, // "001 Titel" /^\s*(\d{1,3})\s*[-โ€“โ€”._:]+\s*/, // "17 - Titel" ]; function splitEpisodeNumber(name) { for (const re of EPISODE_PATTERNS) { const m = name.match(re); if (!m) continue; const rest = name.slice(m[0].length).trim(); if (!rest) break; // the number *is* the name return { number: parseInt(m[1], 10), rest }; } return { number: null, rest: name }; } 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]; // the number wrecks the Audible search, so it becomes the volume instead const { number, rest } = splitEpisodeNumber(item.name); quickEpisode = number; $("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name; $("#quick-episode").textContent = number != null ? t("quick.episodeNote", { n: number }) : ""; $("#quick-q").value = cleanFileName(rest); quickPicked = null; $("#quick-req-filter").value = ""; $("#quick-m-title").value = cleanFileName(rest); $("#quick-m-authors").value = ""; $("#quick-m-volume").value = number ?? ""; $("#quick-m-series").value = lastSeries; $("#quick-based-on").hidden = true; $("#quick-manual-summary").textContent = t("quick.manualSummary"); $("#quick-manual").open = false; $("#quick-library").innerHTML = libOptions(item.media_type) || noLibraryOption(); $("#quick-results").innerHTML = ""; renderQuickOpenRequests(item); $("#quick-dialog").showModal(); quickAutoSearch = true; // opening the dialog searches Audible only runQuickSearch(); } // link to an open request instead of creating a duplicate: guesses from the // file name, or a search over all open requests once something is typed const QUICK_OPEN_LIMIT = 20; function renderQuickOpenRequests(item) { const box = $("#quick-open-requests"); const query = $("#quick-req-filter").value.trim().toLowerCase(); const open = missingReqs.filter((r) => r.media_type === item.media_type); const hay = (r) => `${r.title} ${r.authors} ${r.series} ${r.narrator} ${r.volume ?? ""}`.toLowerCase(); let matches; let heading; if (query) { const terms = query.split(/\s+/); matches = open.filter((r) => terms.every((t) => hay(r).includes(t))); heading = matches.length ? t("quick.hits", { shown: Math.min(matches.length, QUICK_OPEN_LIMIT), total: matches.length, open: open.length }) : t("quick.noHits", { open: open.length }); matches = matches.slice(0, QUICK_OPEN_LIMIT); } else { const tokens = cleanFileName(item.name).toLowerCase().split(" ").filter((t) => t.length > 2); matches = open .map((r) => ({ r, hits: tokens.filter((t) => hay(r).includes(t)).length })) .filter((m) => m.hits >= 2) .sort((a, b) => b.hits - a.hits) .slice(0, 3) .map((m) => m.r); heading = matches.length ? t("quick.matchingOpen") : ""; } box.innerHTML = (heading ? `

${esc(heading)}

` : "") + matches .map( (r) => `
${esc(r.title)}${r.volume != null ? ` ยท ${t("common.volume")} ${r.volume}` : ""} โ€” ${esc(r.authors)} (${esc(r.library_name)})
` ) .join(""); box.querySelectorAll("[data-link-req]").forEach((b) => b.addEventListener("click", () => { const i = quickItemIndex; scanItems[i].request_id = parseInt(b.dataset.linkReq); scanItems[i].checked = true; renderImportTable(); $("#quick-dialog").close(); toast(t("quick.linked")); }) ); } async function runQuickSearch() { const item = scanItems[quickItemIndex]; const box = $("#quick-results"); box.innerHTML = `

${t("quick.searching")}

`; try { // an "english" library should not offer the German edition const lib = libraries.find((l) => String(l.id) === $("#quick-library").value); const results = await api( `/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` + `&language=${lib?.language || ""}&fallback=${quickAutoSearch ? "false" : "true"}` ); quickAutoSearch = false; if (!results.length) { box.innerHTML = `

${esc(t(EMPTY_HINTS[item.media_type]))}

`; return; } // one compact row per hit box.innerHTML = results .map((r, j) => { const meta = [ r.authors, r.narrator ? "๐ŸŽ™ " + r.narrator : "", r.series ? `๐Ÿ“š ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "", r.year ?? "", languageName(r.language), r.source && r.source !== "audible" ? SOURCE_NAMES[r.source] || r.source : "", ].filter(Boolean).map(esc).join(" ยท "); return `
${r.cover_url ? `` : '
?
'}
${esc(r.title)} ${meta}
`; }) .join(""); box.querySelectorAll("[data-pick]").forEach((btn) => btn.addEventListener("click", () => pickQuickResult(results[btn.dataset.pick])) ); box.querySelectorAll("[data-edit]").forEach((btn) => btn.addEventListener("click", () => editBeforeRequest(results[btn.dataset.edit])) ); } catch (err) { box.innerHTML = ""; toast(err.message, true); } } $("#quick-req-filter").addEventListener("input", () => { if (quickItemIndex != null) renderQuickOpenRequests(scanItems[quickItemIndex]); }); $("#quick-search-form").addEventListener("submit", (e) => { e.preventDefault(); runQuickSearch(); }); $("#quick-close").addEventListener("click", () => $("#quick-dialog").close()); // create the request and wire it to the scanned entry async function requestAndConnect(body) { const libId = $("#quick-library").value; if (!libId) { toast(t("common.needLibraryFirst"), true); return; } const i = quickItemIndex; try { const req = await createRequest({ library_id: parseInt(libId), ...body }); 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(t("quick.requestedAndLinked", { title: req.title })); } catch (err) { toast(err.message, true); } } function pickQuickResult(r) { return requestAndConnect({ title: r.title, authors: r.authors, narrator: r.narrator || "", external_id: r.external_id, volume: r.volume ?? quickEpisode, year: r.year, series: r.series, cover_url: r.cover_url, }); } // take a hit into the fields so it can be corrected first - Audible files // Harry Potter under "Wizarding World", which would end up in the folder name function editBeforeRequest(r) { quickPicked = r; $("#quick-m-title").value = r.title || ""; $("#quick-m-authors").value = r.authors || ""; $("#quick-m-series").value = r.series || ""; $("#quick-m-volume").value = r.volume ?? quickEpisode ?? ""; $("#quick-based-on").textContent = t("quick.basedOn", { title: r.title }); $("#quick-based-on").hidden = false; $("#quick-manual-summary").textContent = t("quick.editSummary"); $("#quick-manual").open = true; $("#quick-m-title").focus(); } $("#quick-m-submit").addEventListener("click", () => { const title = $("#quick-m-title").value.trim(); if (!title) { toast(t("quick.needTitle"), true); return; } const volume = $("#quick-m-volume").value; const base = quickPicked ? { narrator: quickPicked.narrator || "", external_id: quickPicked.external_id, year: quickPicked.year, cover_url: quickPicked.cover_url } : {}; return requestAndConnect({ ...base, title, authors: $("#quick-m-authors").value.trim(), series: (lastSeries = $("#quick-m-series").value.trim()), volume: volume ? parseInt(volume) : null, }); }); // ---- import execution (batched, with progress) ---- const IMPORT_BATCH_SIZE = 20; // finished rows disappear after a while, the result list keeps the record const IMPORT_DONE_CLEANUP_MS = 15000; let cleanupTimer = null; // several entries on one request: one audiobook split across folders function groupByRequest(chosen) { const byRequest = new Map(); for (const item of chosen) { const group = byRequest.get(item.request_id); if (group) group.push(item); else byRequest.set(item.request_id, [item]); } return [...byRequest.values()]; } function buildImportGroups(groups) { return groups.map((group) => ({ parts: group, item: { path: group.length > 1 ? sharedParent(group) : group[0].path, is_dir: group.length > 1 ? true : group[0].is_dir, files: group.flatMap((it) => it.files), request_id: group[0].request_id, append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import }, })); } // either a multi-part title (merge) or a mismatch (skip), decided once function askAboutSharedRequests(shared) { const dlg = $("#conflict-dialog"); $("#conflict-intro").textContent = t("conflict.intro", { n: shared.length }); $("#conflict-list").innerHTML = shared .map((group) => { const req = missingReqs.find((r) => r.id === group[0].request_id); return ``; }) .join(""); dlg.showModal(); return new Promise((resolve) => { const done = (answer) => { dlg.close(); resolve(answer); }; $("#conflict-merge").onclick = () => done("merge"); $("#conflict-skip").onclick = () => done("skip"); $("#conflict-cancel").onclick = () => done("cancel"); }); } async function collapseSharedRequests(chosen) { const groups = groupByRequest(chosen); scanItems.forEach((it) => (it.conflict = false)); skippedConflicts = 0; const shared = groups.filter((g) => g.length > 1); if (!shared.length) return buildImportGroups(groups); const answer = await askAboutSharedRequests(shared); if (answer === "cancel") return null; if (answer === "merge") return buildImportGroups(groups); // mark and deselect the clashing entries, import everything else const skipped = shared.flat(); skipped.forEach((it) => { it.conflict = true; it.checked = false; }); skippedConflicts = shared.length; return buildImportGroups(groups.filter((g) => g.length === 1)); } // deepest folder that holds all of the group's entries function sharedParent(group) { // base64 paths carry no readable separators, so only the scanner's own // parent dir says anything about them if (group.some((it) => it.path.startsWith("b64:"))) { const dirs = new Set(group.map((it) => it.dir)); return dirs.size === 1 ? group[0].dir : group[0].path; } const parts = group.map((it) => it.path.split("/")); const first = parts[0]; let i = 0; while (i < first.length - 1 && parts.every((p) => p[i] === first[i])) i++; return first.slice(0, i).join("/") || group[0].path; } $("#import-btn").addEventListener("click", async () => { const chosen = scanItems.filter((item) => item.checked && item.request_id); if (!chosen.length) { toast(t("import.nothingSelected"), true); return; } const groups = await collapseSharedRequests(chosen); if (!groups) return; if (!groups.length) { $("#import-summary").innerHTML = `

${esc(t("import.nothingImported"))}

`; renderImportTable(); return; } const btn = $("#import-btn"); btn.disabled = true; scanItems.forEach((it) => { it.importState = null; it.importError = ""; }); const total = groups.length; const allResults = []; let done = 0; setImportProgress(0, total); try { // one request per entry, otherwise a row cannot turn green on its own for (const { item, parts } of groups) { parts.forEach((p) => (p.importState = "running")); // follow the running entry across pages applyImportView(); const pos = viewIdx.indexOf(scanItems.indexOf(parts[0])); if (pos >= 0) importPage = Math.floor(pos / IMPORT_PAGE_SIZE); renderImportTable(); let result; try { const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items: [item] }), }); result = res.results[0]; } catch (err) { result = { path: item.path, ok: false, error: err.message }; } allResults.push(result); parts.forEach((p) => { p.importState = result.ok ? "done" : "failed"; p.importError = result.ok ? "" : result.error; p.checked = false; }); done += 1; setImportProgress(done, total); renderImportTable(); } const failed = allResults.filter((r) => !r.ok); $("#import-summary").innerHTML = `

` + esc(t("import.summary", { n: allResults.length - failed.length })) + (failed.length ? esc(t("import.summaryFailed", { n: failed.length })) : "") + `

` + (skippedConflicts ? `

${esc(t("import.summarySkipped", { n: skippedConflicts }))}

` : ""); $("#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 ? t("import.finishedFailed", { n: failed.length }) : t("import.finished"), failed.length > 0); refreshMissingBadge(); clearTimeout(cleanupTimer); cleanupTimer = setTimeout(() => { // failures and conflicts stay const before = scanItems.length; scanItems = scanItems.filter((it) => it.importState !== "done"); if (scanItems.length !== before) { importPage = 0; setImportProgress(0, 0); renderImportTable(); $("#scan-info").textContent = t("import.remaining", { n: scanItems.length }); } }, IMPORT_DONE_CLEANUP_MS); // the list stays: rescanning would wipe the feedback } finally { btn.disabled = false; btn.textContent = t("import.run"); } }); function setImportProgress(done, total) { const box = $("#import-progress"); if (!total) { box.hidden = true; return; } if (done >= total) { box.hidden = done < total; if (done >= total) { box.querySelector("progress").value = total; box.querySelector("span").textContent = t("import.progressDone", { total }); } return; } box.hidden = false; const bar = box.querySelector("progress"); bar.max = total; bar.value = done; box.querySelector("span").textContent = `${done}/${total} ยท ${Math.round((done / total) * 100)} %`; } // ---- language ---- function updateLangButton() { $("#lang-toggle").textContent = lang === "de" ? "EN" : "DE"; } $("#lang-toggle").addEventListener("click", () => setLang(lang === "de" ? "en" : "de")); // the static labels are handled by applyI18n, everything rendered from data is not function onLangChange() { updateLangButton(); renderSearchResults(); renderRequests("missing"); renderRequests("imported"); if (libraries.length) loadLibraries(); const editId = $("#lib-form").dataset.editId; editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit(); if (scanItems.length) renderImportTable(); } // ---- version ---- // the build stamp is the quickest answer to "did the redeploy arrive?" async function showVersion() { try { const v = await api("/api/version"); // the version alone rarely moves, so the stamp is what tells deploys apart $("#app-version").textContent = `v${v.version} ยท ${v.build || v.built_at}`; $("#app-version").title = `${v.built_at} UTC${v.build ? " ยท " + v.build : ""}`; } catch {} } // ---- init ---- applyI18n(); updateLangButton(); endLibraryEdit(); loadLibraries(); refreshMissingBadge(); showVersion();