Files
wordarr/static/app.js
T

1571 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
let libraries = [];
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
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 + "\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);
}
// quotes included: the result also lands inside attributes (value="${esc(...)}")
const ESC_CHARS = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
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 libOptions(mediaType) {
const last = getLastLib(mediaType);
return libraries
.filter((l) => l.media_type === mediaType)
.map((l) => `<option value="${l.id}" ${String(l.id) === last ? "selected" : ""}>${esc(l.name)}</option>`)
.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) => `<tr>
<td>${esc(l.name)}</td><td>${esc(l.media_type)}</td><td>${esc(l.root_path)}</td>
<td class="mono">${esc(l.folder_template)}</td><td class="mono">${esc(l.file_template)}</td>
<td>${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"}</td>
<td class="row">
<button class="secondary" data-edit-lib="${l.id}">Bearbeiten</button>
<button class="danger" data-del-lib="${l.id}">Löschen</button>
</td>
</tr>`
)
.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 =
'<option value="">Alle Libraries</option>' +
libraries.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).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) => `<option value="${esc(d)}">`).join("");
} catch {}
}, 150);
});
}
// ---- search & request ----
const EMPTY_HINTS = {
ebook: "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen — und ggf. „Alle Sprachen“ wählen.",
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 = '<span class="spinner"></span> 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 = `<p class='muted'>${EMPTY_HINTS[type]}</p>`;
return;
}
const opts = libOptions(type);
box.innerHTML = results
.map(
(r, i) => `<div class="card">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.source && r.source !== "audible" ? "· " + esc(SOURCE_NAMES[r.source] || r.source) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
<div class="row">
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
<button data-req="${i}">Anfragen</button>
${r.series_id ? `<button class="secondary" data-series="${i}">Ganze Serie…</button>` : ""}
</div>
</div>
</div>`
)
.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) => {
// the crawl cannot reach every episode, show the holes
let gap = "";
if (e.volume != null && previous != null && e.volume > previous + 1) {
const missing = e.volume - previous - 1;
gap = `<div class="gap">… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}${e.volume - 1})</div>`;
}
if (e.volume != null) previous = e.volume;
return `${gap}<label>
<input type="checkbox" data-ep="${i}" ${inRange(e) ? "checked" : ""}>
<span class="num">${e.volume ?? "—"}</span>
<span>${esc(e.title)}</span>
</label>`;
})
.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 = '<span class="spinner"></span> Folgen werden gesucht…';
$("#series-list").innerHTML = "";
$("#series-from").value = "";
$("#series-to").value = "";
$("#series-library").innerHTML = libOptions("audiobook") || "<option value=''>— keine Library —</option>";
$("#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 = '<span class="spinner"></span> 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) || "<option value=''>— keine Library —</option>";
};
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 = "<p class='muted'>Nichts hier.</p>";
bulkBtn.hidden = true;
return;
}
box.innerHTML = reqs
.map(
(r) => `<div class="card clickable" data-detail="${r.id}">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
<span class="muted">${esc(r.media_type)}${esc(r.library_name)}</span>
${status === "imported" ? `<span class="muted mono">${esc(r.imported_path)}</span>` : ""}
</div>
<input type="checkbox" class="card-check" data-sel-req="${r.id}">
</div>`
)
.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) => `<option value="${l.id}" ${l.id === r.library_id ? "selected" : ""}>${esc(l.name)}</option>`)
.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 || "",
}),
});
}
// 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 = '<span class="spinner"></span> 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 = '<span class="spinner"></span> 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 = '<span class="spinner"></span> 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 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 = `${scan.items.length} Kandidat(en) in ${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(".", ",")} ${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 `<span class="tag fmt">${esc(formats.join(", "))}${size ? " · " + size : ""}</span>`;
}
function formatOptions() {
const all = [...new Set(scanItems.flatMap((it) => it.formats || []))].sort();
const current = $("#import-filter-format").value;
$("#import-filter-format").innerHTML =
'<option value="">Alle Formate</option>' +
all.map((f) => `<option value="${esc(f)}" ${f === current ? "selected" : ""}>.${esc(f)}</option>`).join("");
}
function scoreCell(item) {
if (!item.suggested_request_id) return '<td class="score-none">—</td>';
const cls = item.score >= 80 ? "score-hi" : item.score >= 50 ? "score-mid" : "score-lo";
return `<td class="${cls}">${item.score}</td>`;
}
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 = "") =>
`<option value="${r.id}" ${r.id === item.request_id ? "selected" : ""}>
${esc(r.title)}${esc(r.authors)} (${esc(r.library_name)})${suffix}</option>`;
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 `<tr class="${item.importState ? "row-" + item.importState : item.conflict ? "row-conflict" : ""}">
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
<td class="mono">
${esc(item.name)}${item.is_dir ? " 📁" : ""}
${formatTag(item)}
${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""}
${!item.parts && item.maybe_separate
? `<span class="tag warn" title="Die ${item.files.length} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen">${item.files.length} Titel?</span>`
: ""}
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
</td>
<td>${esc(item.media_type)}</td>
<td class="row">
<select data-select="${i}"><option value="">— überspringen —</option>${opts}
${appendOpts ? `<optgroup label="Bereits importiert — Teile anhängen">${appendOpts}</optgroup>` : ""}
</select>
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
${item.parts || item.maybe_separate
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
: ""}
</td>
<td>${libraryCell(item, i)}</td>
${item.importState ? importStateCell(item)
: item.conflict ? '<td class="state conflict" title="Mehrere Einträge zeigen auf dieselbe Anfrage">⚠ Konflikt</td>'
: scoreCell(item)}
</tr>`;
})
.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);
}
// 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 '<span class="muted">—</span>';
const opts = libraries
.filter((l) => l.media_type === req.media_type)
.map((l) => `<option value="${l.id}" ${l.id === req.library_id ? "selected" : ""}>${esc(l.name)}</option>`)
.join("");
return `<select data-lib-for="${i}" title="Ziel-Library dieser Anfrage ändern">${opts}</select>`;
}
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 '<td class="state running"><span class="spinner"></span> läuft…</td>';
}
if (item.importState === "done") return '<td class="state done">✓ importiert</td>';
return `<td class="state failed" title="${esc(item.importError)}">✗ Fehler</td>`;
}
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-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, "dieser Seite");
});
$("#import-select-all").addEventListener("click", () => {
applyImportView();
selectIndices(viewIdx, "insgesamt");
});
// entries without a suggestion are included, "Anfragen aus Ordnernamen" 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(
`${indices.length} Einträge ${what} ausgewählt` +
(withReq < indices.length ? `, davon ${indices.length - withReq} ohne Zuordnung` : "")
);
}
$("#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("Keine ausgewählten Einträge ohne Zuordnung", true);
return;
}
const type = namesTargets[0].media_type;
namesTargets = namesTargets.filter((it) => it.media_type === type);
$("#names-intro").textContent =
`${namesTargets.length} Einträge ohne Zuordnung (${type}). Titel und Folgennummer ` +
"kommen aus dem Ordnernamen; Serie und Autor gelten für alle.";
$("#names-series").value = lastSeries;
$("#names-authors").value = "";
$("#names-library").innerHTML = libOptions(type) || "<option value=''>— keine Library —</option>";
renderNamesPreview();
$("#names-dialog").showModal();
});
function renderNamesPreview() {
const shown = namesTargets.slice(0, 12);
$("#names-preview").innerHTML =
shown
.map((it) => {
const { title, volume } = nameToRequest(it);
return `<label><span class="num">${volume ?? "—"}</span><span>${esc(title)}</span></label>`;
})
.join("") +
(namesTargets.length > shown.length
? `<div class="gap">… und ${namesTargets.length - shown.length} weitere</div>`
: "");
}
$("#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("Erst eine Library für diesen Typ anlegen", true); return; }
const series = (lastSeries = $("#names-series").value.trim());
const authors = $("#names-authors").value.trim();
const btn = $("#names-submit");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Lege an…';
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(
`${res.created.length} Anfragen angelegt` +
(res.skipped ? `, ${res.skipped} gab es schon` : "") +
`, ${linked} Einträge verbunden`
);
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
btn.textContent = "Anlegen & verbinden";
}
});
// ---- 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("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;
}
// "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(`${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) {
// 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(`„${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 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 ? `Folge ${number} — nicht mitgesucht` : "";
$("#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 = "Nicht bei Audible? Manuell anlegen";
$("#quick-manual").open = false;
$("#quick-library").innerHTML =
libOptions(item.media_type) || "<option value=''>— keine Library —</option>";
$("#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
? `${Math.min(matches.length, QUICK_OPEN_LIMIT)} von ${matches.length} Treffern (${open.length} offen)`
: `Kein Treffer unter ${open.length} offenen Anfragen`;
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 ? "Passende offene Requests:" : "";
}
box.innerHTML =
(heading ? `<p class="muted">${esc(heading)}</p>` : "") +
matches
.map(
(r) => `<div class="row open-req">
<span>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}${esc(r.authors)} (${esc(r.library_name)})</span>
<button class="secondary" data-link-req="${r.id}">Verbinden</button>
</div>`
)
.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 = "<p class='muted'>Suche läuft…</p>";
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 = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
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 ?? "",
LANGUAGE_NAMES[r.language] || r.language,
r.source && r.source !== "audible" ? SOURCE_NAMES[r.source] || r.source : "",
].filter(Boolean).map(esc).join(" · ");
return `<div class="card">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}</strong>
<span class="muted">${meta}</span>
</div>
<button data-edit="${j}" class="secondary" title="Felder vor dem Anlegen anpassen (z.B. Serie)">✎</button>
<button data-pick="${j}" title="Anfrage anlegen und mit diesem Eintrag verbinden">Verbinden</button>
</div>`;
})
.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("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 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 =
`Ü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 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 =
`${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 `<label><span>${esc(req ? req.title : "#" + group[0].request_id)}</span>
<span class="muted">← ${group.map((it) => esc(it.name)).join(" + ")}</span></label>`;
})
.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("Nichts ausgewählt", true); return; }
const groups = await collapseSharedRequests(chosen);
if (!groups) return;
if (!groups.length) {
$("#import-summary").innerHTML =
`<p class="error">Nichts importiert: alle ausgewählten Einträge waren mehreren ` +
`Anfragen zugeordnet — Filter „Nur Konflikte" zeigt sie.</p>`;
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 =
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
`${allResults.length - failed.length} importiert` +
(failed.length ? `, ${failed.length} Fehler` : "") +
`</strong></p>` +
(skippedConflicts
? `<p class="error">${skippedConflicts} Anfrage(n) waren mehreren Einträgen ` +
`zugeordnet und wurden übersprungen — Filter „Nur Konflikte" zeigt sie.</p>`
: "");
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
.map((r) =>
r.ok
? `<p class="ok">✓ ${esc(r.path)}${esc(r.dest)}</p>`
: `<p class="error">✗ ${esc(r.path)}: ${esc(r.error)}</p>`
)
.join("");
toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", 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 =
`${scanItems.length} Kandidat(en) übrig — für den aktuellen Stand erneut scannen`;
}
}, IMPORT_DONE_CLEANUP_MS);
// the list stays: rescanning would wipe the feedback
} 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();