const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
let libraries = [];
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
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();
}
// create a request; on a duplicate warning (409) ask the user 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 + "\nTrotzdem anlegen?")) {
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);
}
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) => `${esc(l.name)} `)
.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)}
${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"}
Bearbeiten
Löschen
`
)
.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("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 =
'Alle Libraries ' +
libraries.map((l) => `${esc(l.name)} `).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 = `Library „${lib.name}“ bearbeiten`;
$("#lib-form-submit").textContent = "Speichern";
$("#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 = "Neue Library";
$("#lib-form-submit").textContent = "Anlegen";
$("#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("Library gespeichert — gilt für künftige Importe");
} else {
await api("/api/libraries", { method: "POST", body: JSON.stringify(data) });
toast("Library angelegt");
}
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("");
} catch {}
}, 150);
});
}
// ---- search & request ----
const EMPTY_HINTS = {
ebook: "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen.",
audiobook: "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.",
comic: "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen“ erfassen.",
};
$("#search-type").addEventListener("change", () => ($("#search-results").innerHTML = ""));
$("#search-form").addEventListener("submit", async (e) => {
e.preventDefault();
const type = $("#search-type").value;
const q = $("#search-q").value;
const box = $("#search-results");
const searchBtn = $("#search-btn");
searchBtn.disabled = true;
searchBtn.innerHTML = ' Suche…';
try {
const language = $("#search-language").value;
const results = await api(
`/api/search?media_type=${type}&q=${encodeURIComponent(q)}&language=${language}`
);
if (!results.length) {
box.innerHTML = `${EMPTY_HINTS[type]}
`;
return;
}
const opts = libOptions(type);
box.innerHTML = results
.map(
(r, i) => `
${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 ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}
${type === "comic" ? `
` : ""}
${opts || "— keine Library — "}
Anfragen
${r.series_id ? `Ganze Serie… ` : ""}
`
)
.join("");
box.querySelectorAll("[data-series]").forEach((btn) =>
btn.addEventListener("click", () => openSeriesDialog(results[btn.dataset.series]))
);
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 createRequest({
library_id: parseInt(libId),
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id,
year: r.year, series: r.series, cover_url: r.cover_url,
volume: volInput && volInput.value ? parseInt(volInput.value) : (r.volume ?? null),
});
setLastLib(type, libId);
btn.textContent = "✓ Angefragt";
btn.disabled = true;
refreshMissingBadge();
} catch (err) { toast(err.message, true); }
})
);
} catch (err) {
box.innerHTML = "";
toast(err.message, true);
} finally {
searchBtn.disabled = false;
searchBtn.textContent = "Suchen";
}
});
// ---- request a whole series ----
let seriesEpisodes = [];
function renderSeriesList() {
const from = parseInt($("#series-from").value);
const to = parseInt($("#series-to").value);
const inRange = (e) =>
(isNaN(from) || (e.volume != null && e.volume >= from)) &&
(isNaN(to) || (e.volume != null && e.volume <= to));
let previous = null;
$("#series-list").innerHTML = seriesEpisodes
.map((e, i) => {
// make holes in the numbering visible - the crawl cannot reach every episode
let gap = "";
if (e.volume != null && previous != null && e.volume > previous + 1) {
const missing = e.volume - previous - 1;
gap = `… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}–${e.volume - 1})
`;
}
if (e.volume != null) previous = e.volume;
return `${gap}
${e.volume ?? "—"}
${esc(e.title)}
`;
})
.join("");
updateSeriesCount();
}
function seriesChecked() {
return [...$("#series-list").querySelectorAll("input:checked")].map(
(c) => seriesEpisodes[c.dataset.ep]
);
}
function updateSeriesCount() {
const n = seriesChecked().length;
const btn = $("#series-submit");
btn.textContent = n ? `${n} anfragen` : "Anfragen";
btn.disabled = n === 0;
}
async function openSeriesDialog(result) {
const dlg = $("#series-dialog");
$("#series-title").textContent = result.series || "Serie";
$("#series-info").innerHTML = ' Folgen werden gesucht…';
$("#series-list").innerHTML = "";
$("#series-from").value = "";
$("#series-to").value = "";
$("#series-library").innerHTML = libOptions("audiobook") || " — keine Library — ";
$("#series-submit").disabled = true;
dlg.showModal();
try {
seriesEpisodes = await api(
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
`&title=${encodeURIComponent(result.series || result.title)}`
);
} catch (err) {
$("#series-info").textContent = err.message;
return;
}
const numbered = seriesEpisodes.filter((e) => e.volume != null);
$("#series-info").textContent =
`${seriesEpisodes.length} Folgen gefunden` +
(numbered.length ? ` (Nr. ${numbered[0].volume}–${numbered[numbered.length - 1].volume})` : "") +
". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.";
renderSeriesList();
}
$("#series-from").addEventListener("input", renderSeriesList);
$("#series-to").addEventListener("input", renderSeriesList);
$("#series-list").addEventListener("change", updateSeriesCount);
$("#series-all").addEventListener("click", () => {
$("#series-list").querySelectorAll("input").forEach((c) => (c.checked = true));
updateSeriesCount();
});
$("#series-none").addEventListener("click", () => {
$("#series-list").querySelectorAll("input").forEach((c) => (c.checked = false));
updateSeriesCount();
});
$("#series-cancel").addEventListener("click", () => $("#series-dialog").close());
$("#series-submit").addEventListener("click", async () => {
const libId = $("#series-library").value;
if (!libId) { toast("Erst eine Audiobook-Library anlegen (Tab Libraries)", true); return; }
const items = seriesChecked().map((e) => ({
title: e.title, authors: e.authors, narrator: e.narrator || "",
external_id: e.external_id, year: e.year, series: e.series,
volume: e.volume, cover_url: e.cover_url,
}));
const btn = $("#series-submit");
btn.disabled = true;
btn.innerHTML = ' Lege an…';
try {
const res = await api("/api/requests/bulk-items", {
method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }),
});
setLastLib("audiobook", libId);
toast(
`${res.created.length} Anfragen angelegt` +
(res.skipped ? `, ${res.skipped} bereits vorhanden` : "")
);
$("#series-dialog").close();
refreshMissingBadge();
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
updateSeriesCount();
}
});
// ---- 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) || "— keine Library — ";
};
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 createRequest({
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: [] };
// view mode (Liste / kleine / große Symbole), shared by both lists
function getViewMode() {
try { return localStorage.getItem("wordarr.viewMode") || "small"; } catch { return "small"; }
}
function applyViewMode(mode) {
try { localStorage.setItem("wordarr.viewMode", mode); } catch {}
["#missing-list", "#imported-list"].forEach((sel) => {
const box = $(sel);
box.classList.remove("view-list", "view-small", "view-large");
box.classList.add("view-" + mode);
});
$$(".view-toggle button").forEach((b) =>
b.classList.toggle("active", b.dataset.viewmode === mode)
);
}
$$(".view-toggle button").forEach((b) =>
b.addEventListener("click", () => applyViewMode(b.dataset.viewmode))
);
applyViewMode(getViewMode());
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");
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
if (!reqs.length) {
box.innerHTML = "Nichts hier.
";
bulkBtn.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)} ` : ""}
`
)
.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);
})
);
const updateBulkBtn = () => {
const n = box.querySelectorAll("[data-sel-req]:checked").length;
bulkBtn.hidden = n === 0;
bulkBtn.textContent = `Ausgewählte entfernen (${n})`;
};
box.querySelectorAll("[data-sel-req]").forEach((c) =>
c.addEventListener("change", updateBulkBtn)
);
updateBulkBtn();
}
async function bulkDeleteRequests(status) {
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const ids = [...box.querySelectorAll("[data-sel-req]:checked")].map((c) => c.dataset.selReq);
if (!ids.length) return;
const hint = status === "imported"
? `${ids.length} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.`
: `${ids.length} Anfrage(n) entfernen?`;
if (!confirm(hint)) return;
for (const id of ids) {
try { await api("/api/requests/" + id, { method: "DELETE" }); }
catch (err) { toast(err.message, true); }
}
toast(`${ids.length} entfernt`);
loadRequests(status);
}
$("#imported-bulk-delete").addEventListener("click", () => bulkDeleteRequests("imported"));
function selectAllRequests(status) {
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const checks = [...box.querySelectorAll("[data-sel-req]")];
const allChecked = checks.length && checks.every((c) => c.checked);
checks.forEach((c) => (c.checked = !allChecked));
if (checks.length) checks[0].dispatchEvent(new Event("change"));
}
$("#missing-select-all").addEventListener("click", () => selectAllRequests("missing"));
$("#imported-select-all").addEventListener("click", () => selectAllRequests("imported"));
$("#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", () => bulkDeleteRequests("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) => `${esc(l.name)} `)
.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-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 || "",
}),
});
}
// after correcting series/title/author the files are still filed under the old
// name - this re-applies the library's scheme to what is already on disk
$("#detail-relocate").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-relocate");
btn.disabled = true;
btn.innerHTML = ' Lege ab…';
try {
await saveDetail($("#detail-form"));
const res = await api(`/api/requests/${detailRequest.id}/relocate`, { method: "POST" });
toast(res.moved ? `Neu abgelegt: ${res.dest}` : "Liegt bereits richtig");
$("#detail-dialog").close();
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
finally {
btn.disabled = false;
btn.textContent = "Speichern & neu ablegen";
}
});
$("#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 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 = ' Scanne…';
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 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;
});
orphanedIds = new Set(scan.orphaned_request_ids || []);
// an imported title whose files vanished is selectable like an open one
missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
importedReqs = done.filter((r) => !orphanedIds.has(r.id));
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;
if (mode === "conflict" && !item.conflict) 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 option = (r, suffix = "") =>
`
${esc(r.title)} — ${esc(r.authors)} (${esc(r.library_name)})${suffix} `;
const opts = missingReqs
.filter((r) => r.media_type === item.media_type)
.map((r) => option(r, orphanedIds.has(r.id) ? " ↺" : ""))
.join("");
// already imported audiobooks can take further parts (a late CD, or one
// that failed while its siblings went through)
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 ? " 📁" : ""}
${item.parts ? `${item.parts.length} Teile · ${item.files.length} Dateien ` : ""}
${!item.parts && item.maybe_separate
? `${item.files.length} Titel? `
: ""}
${item.rel_dir ? `${esc(item.rel_dir)}/
` : ""}
${esc(item.media_type)}
— überspringen — ${opts}
${appendOpts ? `${appendOpts} ` : ""}
🔍
${item.parts || item.maybe_separate
? `✂️ `
: ""}
${libraryCell(item, i)}
${item.importState ? importStateCell(item)
: item.conflict ? '⚠ Konflikt '
: scoreCell(item)}
`;
})
.join("");
table.hidden = false;
$("#import-btn").hidden = false;
const showsState = scanItems.some((it) => it.importState);
table.querySelector("thead th:last-child").textContent = showsState ? "Status" : "Score";
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(`„${req.title}" → ${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);
}
// which library the assigned request writes to - visible in the row, because the
// select inside the search dialog is easy to miss
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) => `${esc(l.name)} `)
.join("");
return `${opts} `;
}
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 ' läuft… ';
}
if (item.importState === "done") return '✓ importiert ';
return `✗ Fehler `;
}
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-select-page").addEventListener("click", () => {
applyImportView();
const start = importPage * IMPORT_PAGE_SIZE;
const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
// exclusive on purpose: "import what I see", not "add to what was selected"
scanItems.forEach((it) => (it.checked = false));
let n = 0;
pageIdx.forEach((i) => {
const item = scanItems[i];
const req = item.request_id || item.suggested_request_id;
if (!req) return;
item.request_id = req;
item.checked = true;
n += 1;
});
renderImportTable();
toast(n ? `${n} Einträge dieser Seite ausgewählt` : "Auf dieser Seite gibt es keine Zuordnung", !n);
});
$("#import-deselect-all").addEventListener("click", () => {
applyImportView();
viewIdx.forEach((i) => (scanItems[i].checked = false));
renderImportTable();
});
// ---- multi-part episodes: import several entries as one audiobook ----
// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title
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: "100 - Toteninsel Teil " -> "100 - Toteninsel"
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("Mindestens zwei Einträge auswählen", true);
return;
}
if (new Set(chosen.map((it) => it.media_type)).size > 1) {
toast("Nur Einträge desselben Typs lassen sich zusammenfassen", true);
return;
}
const parents = new Set(chosen.map((it) => it.rel_dir));
if (parents.size > 1) {
toast("Die Einträge liegen in verschiedenen Ordnern", true);
return;
}
// parts may share no prefix at all ("A - Sphinx", "B - Volk"): then the
// folder holding them carries the real title ("100 - Toteninsel")
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].path.slice(0, chosen[0].path.length - chosen[0].name.length - 1) || chosen[0].path,
name,
rel_dir: chosen[0].rel_dir,
media_type: chosen[0].media_type,
is_dir: true,
files: chosen.flatMap((it) => it.files),
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(`${chosen.length} Einträge als „${name}" zusammengefasst (${merged.files.length} Dateien)`);
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) {
// a folder holding one book per file (a series bought as single titles):
// turn every file into its own entry so each can get its own request
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],
checked: false,
request_id: null,
suggested_request_id: null,
score: null,
}));
scanItems.splice(i, 1, ...parts);
toast(`„${item.name}" in ${parts.length} Einzeltitel aufgeteilt`);
} 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 lastSeries = "";
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
// A bare number without a separator ("100 Stunden") or without a leading zero is
// left alone: there it is part of the title, not a numbering.
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 episode number wrecks the Audible keyword search, so it is kept out of
// the query and used as 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 ? `Folge ${number} — nicht mitgesucht` : "";
$("#quick-q").value = cleanFileName(rest);
quickPicked = null;
$("#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 = "Nicht bei Audible? Manuell anlegen";
$("#quick-manual").open = false;
$("#quick-library").innerHTML =
libOptions(item.media_type) || "— keine Library — ";
$("#quick-results").innerHTML = "";
renderQuickOpenRequests(item);
$("#quick-dialog").showModal();
runQuickSearch();
}
// offer linking to similar already-open requests instead of creating a duplicate
function renderQuickOpenRequests(item) {
const box = $("#quick-open-requests");
const tokens = cleanFileName(item.name).toLowerCase().split(" ").filter((t) => t.length > 2);
const matches = missingReqs
.filter((r) => r.media_type === item.media_type)
.map((r) => {
const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase();
return { r, hits: tokens.filter((t) => hay.includes(t)).length };
})
.filter((m) => m.hits >= 2)
.sort((a, b) => b.hits - a.hits)
.slice(0, 3);
if (!matches.length) { box.innerHTML = ""; return; }
box.innerHTML =
'Passende offene Requests:
' +
matches
.map(
({ r }) => `
${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""} — ${esc(r.authors)} (${esc(r.library_name)})
Verbinden
`
)
.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("Mit bestehendem Request verbunden");
})
);
}
async function runQuickSearch() {
const item = scanItems[quickItemIndex];
const box = $("#quick-results");
box.innerHTML = "Suche läuft…
";
try {
// the target library decides the language: an "english" library should not
// offer the German edition of the same book
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 || ""}`
);
if (!results.length) {
box.innerHTML = `${EMPTY_HINTS[item.media_type]}
`;
return;
}
// one compact row per hit: cover, title + a single meta line, action right
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 ?? "",
LANGUAGE_NAMES[r.language] || r.language,
].filter(Boolean).map(esc).join(" · ");
return `
${r.cover_url ? `
` : '
?
'}
${esc(r.title)}
${meta}
✎
Verbinden
`;
})
.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-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 the dialog was opened for
async function requestAndConnect(body) {
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 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(`„${req.title}" angefragt und verbunden`);
} 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 title/author/series can be corrected before the
// request is created - Audible files Harry Potter under "Wizarding World", and
// the series ends 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 =
`Übernommen von Audible: „${r.title}" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.`;
$("#quick-based-on").hidden = false;
$("#quick-manual-summary").textContent = "Felder anpassen";
$("#quick-manual").open = true;
$("#quick-m-title").focus();
}
$("#quick-m-submit").addEventListener("click", () => {
const title = $("#quick-m-title").value.trim();
if (!title) { toast("Titel angeben", 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 below keeps the record,
// and a shrinking table makes the remaining work obvious
const IMPORT_DONE_CLEANUP_MS = 15000;
let cleanupTimer = null;
// several entries on one request means one audiobook split across folders:
// importing them one by one only imports the first and fails the rest
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
},
}));
}
// Several entries on one request is either a multi-part title (merge them) or a
// mismatch (skip them). The user decides once, then keeps the rest importable.
function askAboutSharedRequests(shared) {
const dlg = $("#conflict-dialog");
$("#conflict-intro").textContent =
`${shared.length} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge ` +
"zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst " +
"überspringst du sie, korrigierst die Zuordnung und importierst sie später.";
$("#conflict-list").innerHTML = shared
.map((group) => {
const req = missingReqs.find((r) => r.id === group[0].request_id);
return `${esc(req ? req.title : "#" + group[0].request_id)}
← ${group.map((it) => esc(it.name)).join(" + ")} `;
})
.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);
// skip: mark the clashing entries, deselect them, 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) {
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("Nichts ausgewählt", true); return; }
const groups = await collapseSharedRequests(chosen);
if (!groups) return;
if (!groups.length) {
$("#import-summary").innerHTML =
`Nichts importiert: alle ausgewählten Einträge waren mehreren ` +
`Anfragen zugeordnet — Filter „Nur Konflikte" zeigt sie.
`;
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: the row can only turn green once its own move came
// back, and a batch would only report after all of its items finished
for (const { item, parts } of groups) {
parts.forEach((p) => (p.importState = "running"));
// follow the running entry across pages, otherwise only the bar moves
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 =
`` +
`${allResults.length - failed.length} importiert` +
(failed.length ? `, ${failed.length} Fehler` : "") +
`
` +
(skippedConflicts
? `${skippedConflicts} Anfrage(n) waren mehreren Einträgen ` +
`zugeordnet und wurden übersprungen — Filter „Nur Konflikte" zeigt sie.
`
: "");
$("#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();
clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => {
// only the successful ones go; failures and conflicts stay to be dealt with
const before = scanItems.length;
scanItems = scanItems.filter((it) => it.importState !== "done");
if (scanItems.length !== before) {
importPage = 0;
setImportProgress(0, 0);
renderImportTable();
$("#scan-info").textContent =
`${scanItems.length} Kandidat(en) übrig — für den aktuellen Stand erneut scannen`;
}
}, IMPORT_DONE_CLEANUP_MS);
// the list stays as it is, with its green and red rows - rescanning would
// wipe exactly the feedback the user just waited for
} finally {
btn.disabled = false;
btn.textContent = "Ausgewählte importieren";
}
});
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 = `${total}/${total} · fertig`;
}
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)} %`;
}
// ---- init ----
loadLibraries();
refreshMissingBadge();