Files
wordarr/static/app.js
T

1730 lines
65 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 languageName = (code) => (code ? t("lang." + code) : "");
const SOURCE_NAMES = {
musicbrainz: "MusicBrainz",
hardcover: "Hardcover",
googlebooks: "Google Books",
openlibrary: "Open Library",
};
async function api(path, opts = {}) {
const resp = await fetch(path, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!resp.ok) {
let msg = resp.statusText;
try { msg = (await resp.json()).detail || msg; } catch {}
const err = new Error(msg);
err.status = resp.status;
throw err;
}
return resp.json();
}
// on a duplicate warning (409) ask before retrying
async function createRequest(body) {
try {
return await api("/api/requests", { method: "POST", body: JSON.stringify(body) });
} catch (err) {
if (err.status === 409 && confirm(err.message + "\n" + t("detail.duplicateConfirm"))) {
return api("/api/requests?allow_duplicate=true", {
method: "POST", body: JSON.stringify(body),
});
}
throw err;
}
}
function toast(msg, isError = false) {
const t = $("#toast");
t.textContent = msg;
t.className = isError ? "error" : "";
t.hidden = false;
setTimeout(() => (t.hidden = true), 4000);
}
// quotes included: the result also lands inside attributes (value="${esc(...)}")
const ESC_CHARS = { "&": "&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 noLibraryOption() {
return `<option value="">${t("common.noLibrary")}</option>`;
}
function decimalMark() {
return lang === "de" ? "," : ".";
}
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[data-view]").forEach((btn) =>
btn.addEventListener("click", () => {
$$("nav button[data-view]").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
$$("main > section").forEach((s) => (s.hidden = true));
$("#view-" + btn.dataset.view).hidden = false;
if (btn.dataset.view === "missing") loadRequests("missing");
if (btn.dataset.view === "imported") loadRequests("imported");
if (btn.dataset.view === "settings") loadLibraries();
})
);
// ---- libraries ----
async function loadLibraries() {
libraries = await api("/api/libraries");
const tbody = $("#lib-table tbody");
tbody.innerHTML = libraries
.map(
(l) => `<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(languageName(l.language)) : "—"}</td>
<td class="row">
<button class="secondary" data-edit-lib="${l.id}">${t("common.edit")}</button>
<button class="danger" data-del-lib="${l.id}">${t("common.delete")}</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(t("lib.confirmDelete"))) return;
try {
await api("/api/libraries/" + b.dataset.delLib, { method: "DELETE" });
loadLibraries();
} catch (e) { toast(e.message, true); }
})
);
const filter = $("#missing-filter-library");
filter.innerHTML =
`<option value="">${t("list.allLibraries")}</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 = t("lib.edit", { name: lib.name });
$("#lib-form-submit").textContent = t("common.save");
$("#lib-form-cancel").hidden = false;
form.scrollIntoView({ behavior: "smooth" });
}
function endLibraryEdit() {
const form = $("#lib-form");
form.reset();
delete form.dataset.editId;
$("#lib-form-title").textContent = t("lib.new");
$("#lib-form-submit").textContent = t("common.create");
$("#lib-form-cancel").hidden = true;
}
$("#lib-form-cancel").addEventListener("click", endLibraryEdit);
$("#lib-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
if (!data.folder_template) delete data.folder_template;
if (!data.file_template) delete data.file_template;
const editId = e.target.dataset.editId;
try {
if (editId) {
await api("/api/libraries/" + editId, { method: "PUT", body: JSON.stringify(data) });
toast(t("lib.saved"));
} else {
await api("/api/libraries", { method: "POST", body: JSON.stringify(data) });
toast(t("lib.created"));
}
endLibraryEdit();
loadLibraries();
} catch (err) { toast(err.message, true); }
});
// path autocompletion for the library form
{
const pathInput = document.querySelector('#lib-form [name=root_path]');
const datalist = $("#path-suggestions");
let pathTimer;
pathInput.addEventListener("input", () => {
clearTimeout(pathTimer);
const value = pathInput.value;
if (!value.startsWith("/")) return;
pathTimer = setTimeout(async () => {
try {
const res = await api("/api/fs/browse?path=" + encodeURIComponent(value));
datalist.innerHTML = res.dirs.map((d) => `<option value="${esc(d)}">`).join("");
} catch {}
}, 150);
});
}
// ---- search & request ----
const EMPTY_HINTS = {
ebook: "search.emptyEbook",
audiobook: "search.emptyAudiobook",
comic: "search.emptyComic",
};
let lastSearch = null; // kept so a language switch can redraw the hits
$("#search-type").addEventListener("change", () => {
lastSearch = null;
$("#search-results").innerHTML = "";
});
$("#search-form").addEventListener("submit", async (e) => {
e.preventDefault();
const type = $("#search-type").value;
const q = $("#search-q").value;
const searchBtn = $("#search-btn");
searchBtn.disabled = true;
searchBtn.innerHTML = `<span class="spinner"></span> ${t("search.running")}`;
try {
const language = $("#search-language").value;
const results = await api(
`/api/search?media_type=${type}&q=${encodeURIComponent(q)}&language=${language}`
);
lastSearch = { results, type, language };
renderSearchResults();
} catch (err) {
lastSearch = null;
$("#search-results").innerHTML = "";
toast(err.message, true);
} finally {
searchBtn.disabled = false;
searchBtn.textContent = t("common.search");
}
});
function renderSearchResults() {
if (!lastSearch) return;
const { results, type, language } = lastSearch;
const box = $("#search-results");
if (!results.length) {
box.innerHTML = `<p class='muted'>${esc(t(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(languageName(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="${t("common.volume")}" class="volume" data-vol="${i}">` : ""}
<div class="row">
<select data-lib="${i}">${opts || noLibraryOption()}</select>
<button data-req="${i}">${t("common.request")}</button>
${r.series_id ? `<button class="secondary" data-series="${i}">${t("search.wholeSeries")}</button>` : ""}
</div>
</div>
</div>`
)
.join("");
box.querySelectorAll("[data-series]").forEach((btn) =>
btn.addEventListener("click", () =>
openSeriesDialog(results[btn.dataset.series], type, language)
)
);
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(t("common.needLibraryFirst"), 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 = t("search.requested");
btn.disabled = true;
refreshMissingBadge();
} catch (err) { toast(err.message, true); }
})
);
}
}
// ---- request a whole series ----
let seriesEpisodes = [];
let seriesType = "audiobook";
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;
const key = seriesType === "ebook"
? (missing > 1 ? "series.gapVolumes" : "series.gapVolume")
: (missing > 1 ? "series.gapEpisodes" : "series.gapEpisode");
gap = `<div class="gap">${esc(t(key, { n: missing, from: previous + 1, to: 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 ? t("series.requestN", { n }) : t("common.request");
btn.disabled = n === 0;
}
async function openSeriesDialog(result, type = "audiobook", language = "") {
const dlg = $("#series-dialog");
seriesType = type;
const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes");
$("#series-title").textContent = result.series || t("series.fallbackTitle");
$("#series-info").innerHTML =
`<span class="spinner"></span> ${esc(t("series.loading", { unit }))}`;
$("#series-list").innerHTML = "";
$("#series-from").value = "";
$("#series-to").value = "";
$("#series-library").innerHTML = libOptions(type) || noLibraryOption();
$("#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)}` +
`&media_type=${type}&language=${encodeURIComponent(language)}` +
`&source=${encodeURIComponent(result.source || "")}`
);
} catch (err) {
$("#series-info").textContent = err.message;
return;
}
const numbered = seriesEpisodes.filter((e) => e.volume != null);
$("#series-info").textContent =
t("series.found", { n: seriesEpisodes.length, unit }) +
(numbered.length
? t("series.range", { from: numbered[0].volume,
to: numbered[numbered.length - 1].volume })
: "") +
t(type === "ebook" ? "series.noteEbook" : "series.noteAudiobook");
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(t("series.needLibrary", { type: seriesType === "ebook" ? "Ebook" : "Audiobook" }), 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> ${t("common.creating")}`;
try {
const res = await api("/api/requests/bulk-items", {
method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }),
});
setLastLib(seriesType, libId);
toast(
t("series.createdN", { n: res.created.length }) +
(res.skipped ? t("series.skippedN", { n: res.skipped }) : "")
);
$("#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) || noLibraryOption();
};
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(t("manual.noLibraryChosen"), true); return; }
try {
if (data.volume_to) {
if (!data.volume) {
toast(t("manual.needStartVolume"), 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(t("manual.createdN", { n: created.length }));
} 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(t("manual.created"));
}
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 (list / small / large icons), 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'>${t("common.nothingHere")}</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 ? ` · ${t("common.volume")} ${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 = t("common.removeSelectedN", { 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 = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests",
{ n: ids.length });
if (!confirm(hint)) return;
for (const id of ids) {
try { await api("/api/requests/" + id, { method: "DELETE" }); }
catch (err) { toast(err.message, true); }
}
toast(t("list.deletedN", { n: ids.length }));
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} · ${t(r.status === "missing" ? "detail.statusMissing" : "detail.statusImported")}`;
$("#detail-path").textContent = r.imported_path || "";
const img = $("#detail-cover");
img.hidden = !r.cover_url;
$("#detail-nocover").hidden = !!r.cover_url;
if (r.cover_url) img.src = r.cover_url;
$("#detail-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook");
$("#detail-relocate").hidden = r.status !== "imported";
$("#detail-dialog").showModal();
}
async function saveDetail(form) {
await api("/api/requests/" + detailRequest.id, {
method: "PUT",
body: JSON.stringify({
library_id: parseInt(form.library_id.value),
title: form.title.value,
authors: form.authors.value,
narrator: form.narrator.value,
series: form.series.value,
volume: form.volume.value ? parseInt(form.volume.value) : null,
year: form.year.value ? parseInt(form.year.value) : null,
external_id: form.external_id.value,
cover_url: detailRequest.cover_url || "",
}),
});
}
// re-applies the library's scheme to files already on disk
$("#detail-relocate").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-relocate");
btn.disabled = true;
btn.innerHTML = `<span class="spinner"></span> ${t("detail.relocating")}`;
try {
await saveDetail($("#detail-form"));
const res = await api(`/api/requests/${detailRequest.id}/relocate`, { method: "POST" });
toast(res.moved ? t("detail.relocated", { dest: res.dest }) : t("detail.alreadyPlaced"));
$("#detail-dialog").close();
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
finally {
btn.disabled = false;
btn.textContent = t("detail.relocate");
}
});
$("#detail-retag").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-retag");
btn.disabled = true;
btn.innerHTML = `<span class="spinner"></span> ${t("detail.retagging")}`;
try {
await saveDetail($("#detail-form"));
const res = await api(`/api/requests/${detailRequest.id}/retag`, { method: "POST" });
toast(t("detail.retagged", { n: res.files }));
$("#detail-dialog").close();
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
finally {
btn.disabled = false;
btn.textContent = t("detail.retag");
}
});
$("#detail-form").addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
if (!detailRequest) return;
try {
await saveDetail(e.target);
toast(t("detail.saved"));
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
});
// ---- import ----
let scanItems = [];
let missingReqs = [];
let importedReqs = [];
let skippedConflicts = 0;
let orphanedIds = new Set(); // imported, but their files are gone from the library
let viewIdx = []; // indices into scanItems after filter/sort
let importPage = 0;
const IMPORT_PAGE_SIZE = 25;
$("#scan-btn").addEventListener("click", async () => {
const scanBtn = $("#scan-btn");
scanBtn.disabled = true;
$("#scan-info").innerHTML = `<span class="spinner"></span> ${t("import.scanning")}`;
try {
const split = $("#split-dirs").checked;
const [scan, reqs, done] = await Promise.all([
api("/api/import/scan?split_dirs=" + split),
api("/api/requests?status=missing"),
api("/api/requests?status=imported"),
]);
clearTimeout(cleanupTimer);
scanItems = scan.items;
// selection lives here, not in the DOM, so it survives paging
scanItems.forEach((item) => {
item.request_id = item.suggested_request_id;
item.checked = !!item.suggested_request_id;
});
orphanedIds = new Set(scan.orphaned_request_ids || []);
// an imported title whose files vanished counts as open again
missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
importedReqs = done.filter((r) => !orphanedIds.has(r.id));
importPage = 0;
$("#scan-info").textContent =
t("import.candidates", { n: scan.items.length, dir: scan.download_dir });
$("#import-toolbar").hidden = scanItems.length === 0;
formatOptions();
renderImportTable();
} catch (err) {
$("#scan-info").textContent = "";
toast(err.message, true);
} finally {
scanBtn.disabled = false;
}
});
function applyImportView() {
const text = $("#import-filter-text").value.toLowerCase();
const mode = $("#import-filter-mode").value;
const format = $("#import-filter-format").value;
const sort = $("#import-sort").value;
viewIdx = scanItems
.map((_, i) => i)
.filter((i) => {
const item = scanItems[i];
if (text && !(`${item.name} ${item.rel_dir || ""}`.toLowerCase().includes(text))) return false;
if (mode === "suggested" && !item.suggested_request_id) return false;
if (mode === "unassigned" && item.request_id) return false;
if (mode === "conflict" && !item.conflict) return false;
if (format && !(item.formats || []).includes(format)) return false;
return true;
});
if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score);
if (sort === "name") viewIdx.sort((a, b) => scanItems[a].name.localeCompare(scanItems[b].name));
}
// the split of a folder happens client side, so the extension comes off the name
function extOf(path) {
const ext = (path.split("/").pop().match(/\.([^.]+)$/) || [])[1];
return ext ? [ext.toLowerCase()] : [];
}
function humanSize(bytes) {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let n = bytes, u = 0;
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
return `${n.toFixed(n < 10 && u > 0 ? 1 : 0).replace(".", decimalMark())} ${units[u]}`;
}
// a Calibre export ships one book in a dozen formats, so the row has to say
// which one it is before anything can be picked
function formatTag(item) {
const formats = item.formats || [];
if (!formats.length) return "";
const size = humanSize(item.size);
return `<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="">${t("import.allFormats")}</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">${t("import.parts", { parts: item.parts.length, files: item.files.length })}</span>` : ""}
${!item.parts && item.maybe_separate
? `<span class="tag warn" title="${esc(t("import.maybeSeparateTitle", { n: item.files.length }))}">${t("import.maybeSeparate", { n: item.files.length })}</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="">${t("import.skip")}</option>${opts}
${appendOpts ? `<optgroup label="${esc(t("import.appendGroup"))}">${appendOpts}</optgroup>` : ""}
</select>
<button class="secondary" data-quick="${i}" title="${esc(t("import.quickTitle"))}">🔍</button>
${item.parts || item.maybe_separate
? `<button class="secondary" data-split="${i}" title="${esc(t(item.parts ? "import.unmerge" : "import.splitDir"))}">✂️</button>`
: ""}
</td>
<td>${libraryCell(item, i)}</td>
${item.importState ? importStateCell(item)
: item.conflict ? `<td class="state conflict" title="${esc(t("import.conflictTitle"))}">${t("import.conflict")}</td>`
: scoreCell(item)}
</tr>`;
})
.join("");
table.hidden = false;
$("#import-btn").hidden = false;
const showsState = scanItems.some((it) => it.importState);
const head = $("#import-score-head");
head.dataset.i18n = showsState ? "common.status" : "common.score";
head.textContent = t(head.dataset.i18n);
tbody.querySelectorAll("[data-quick]").forEach((b) =>
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
);
tbody.querySelectorAll("[data-lib-for]").forEach((sel) =>
sel.addEventListener("change", async () => {
const item = scanItems[sel.dataset.libFor];
const req = requestById(item.request_id);
if (!req) return;
const previous = req.library_id;
req.library_id = parseInt(sel.value);
try {
const saved = await api("/api/requests/" + req.id, {
method: "PUT",
body: JSON.stringify({
library_id: req.library_id, title: req.title, authors: req.authors,
narrator: req.narrator || "", series: req.series, volume: req.volume,
year: req.year, external_id: req.external_id, cover_url: req.cover_url,
}),
});
Object.assign(req, saved);
toast(t("import.movedToLibrary", { title: req.title, library: saved.library_name }));
} catch (err) {
req.library_id = previous;
sel.value = String(previous);
toast(err.message, true);
}
renderImportTable();
})
);
tbody.querySelectorAll("[data-split]").forEach((b) =>
b.addEventListener("click", () => splitItem(parseInt(b.dataset.split)))
);
tbody.querySelectorAll("[data-check]").forEach((c) =>
c.addEventListener("change", () => {
scanItems[c.dataset.check].checked = c.checked;
updateImportPageInfo();
})
);
tbody.querySelectorAll("[data-select]").forEach((s) =>
s.addEventListener("change", () => {
const item = scanItems[s.dataset.select];
item.request_id = s.value ? parseInt(s.value) : null;
item.checked = !!item.request_id;
const check = tbody.querySelector(`[data-check="${s.dataset.select}"]`);
if (check) check.checked = item.checked;
updateImportPageInfo();
})
);
$("#import-pager").hidden = false;
$("#import-prev").disabled = importPage === 0;
$("#import-next").disabled = importPage >= pages - 1;
updateImportPageInfo(pages);
}
// target library of the assigned request, easy to miss inside the dialog
function libraryCell(item, i) {
const req = requestById(item.request_id);
if (!req) return '<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="${esc(t("import.libraryCellTitle"))}">${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> ${t("import.running")}</td>`;
}
if (item.importState === "done") return `<td class="state done">${t("import.done")}</td>`;
return `<td class="state failed" title="${esc(item.importError)}">${t("import.failed")}</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 ? t("import.filtered", { n: viewIdx.length }) : "";
$("#import-page-info").textContent = t("import.pageInfo", {
page: importPage + 1, pages, total: scanItems.length, filtered, selected,
});
}
$("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); });
$("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); });
$("#import-filter-text").addEventListener("input", () => { importPage = 0; renderImportTable(); });
$("#import-filter-mode").addEventListener("change", () => { importPage = 0; renderImportTable(); });
$("#import-filter-format").addEventListener("change", () => { importPage = 0; renderImportTable(); });
$("#import-sort").addEventListener("change", () => { importPage = 0; renderImportTable(); });
$("#import-select-suggested").addEventListener("click", () => {
applyImportView();
viewIdx.forEach((i) => {
const item = scanItems[i];
if (item.suggested_request_id) {
item.request_id = item.request_id || item.suggested_request_id;
item.checked = true;
}
});
renderImportTable();
});
$("#import-select-page").addEventListener("click", () => {
applyImportView();
const start = importPage * IMPORT_PAGE_SIZE;
const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
selectIndices(pageIdx, t("import.thisPage"));
});
$("#import-select-all").addEventListener("click", () => {
applyImportView();
selectIndices(viewIdx, t("import.inTotal"));
});
// entries without a suggestion are included, "requests from folder names" needs them
function selectIndices(indices, what) {
scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add"
indices.forEach((i) => {
const item = scanItems[i];
const req = item.request_id || item.suggested_request_id;
if (req) item.request_id = req;
item.checked = true;
});
renderImportTable();
const withReq = indices.filter((i) => scanItems[i].request_id).length;
toast(
t("import.selectedN", { n: indices.length, what }) +
(withReq < indices.length
? t("import.selectedWithout", { n: indices.length - withReq })
: "")
);
}
$("#import-deselect-all").addEventListener("click", () => {
applyImportView();
viewIdx.forEach((i) => (scanItems[i].checked = false));
renderImportTable();
});
// ---- create requests straight from folder names ----
// for series Audible only knows in part, the folder names carry number and title
let namesTargets = [];
function nameToRequest(item) {
const { title, volume } = parseDownloadName(item.name);
return { title, volume };
}
$("#import-from-names").addEventListener("click", () => {
namesTargets = scanItems.filter((it) => it.checked && !it.request_id);
if (!namesTargets.length) {
toast(t("names.noneWithout"), true);
return;
}
const type = namesTargets[0].media_type;
namesTargets = namesTargets.filter((it) => it.media_type === type);
$("#names-intro").textContent = t("names.intro", { n: namesTargets.length, type });
// the folder names usually carry both, e.g. "Autor - Serie 03 - Titel"
const first = parseDownloadName(namesTargets[0].name);
$("#names-series").value = first.series || lastSeries;
$("#names-authors").value = first.authors;
$("#names-library").innerHTML = libOptions(type) || noLibraryOption();
renderNamesPreview();
$("#names-dialog").showModal();
});
function renderNamesPreview() {
const shown = namesTargets.slice(0, 12);
$("#names-preview").innerHTML =
shown
.map((it) => {
const { title, volume } = nameToRequest(it);
return `<label><span class="num">${volume ?? "—"}</span><span>${esc(title)}</span></label>`;
})
.join("") +
(namesTargets.length > shown.length
? `<div class="gap">${esc(t("names.more", { n: namesTargets.length - shown.length }))}</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(t("common.needLibraryFirst"), true); return; }
const series = (lastSeries = $("#names-series").value.trim());
const authors = $("#names-authors").value.trim();
const btn = $("#names-submit");
btn.disabled = true;
btn.innerHTML = `<span class="spinner"></span> ${t("common.creating")}`;
try {
const items = namesTargets.map((it) => ({ ...nameToRequest(it), series, authors }));
const res = await api("/api/requests/bulk-items", {
method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }),
});
// reload so duplicates can be linked to the existing request
missingReqs = await api("/api/requests?status=missing");
const key = (t, v) => `${(t || "").trim().toLowerCase()}|${v ?? ""}`;
const byKey = new Map(missingReqs.map((r) => [key(r.title, r.volume), r]));
let linked = 0;
namesTargets.forEach((it) => {
const { title, volume } = nameToRequest(it);
const req = byKey.get(key(title, volume));
if (!req) return;
it.request_id = req.id;
it.checked = true;
linked += 1;
});
setLastLib(namesTargets[0].media_type, libId);
$("#names-dialog").close();
renderImportTable();
refreshMissingBadge();
toast(t("names.result", {
created: res.created.length,
skipped: res.skipped ? t("names.existed", { n: res.skipped }) : "",
linked,
}));
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
btn.textContent = t("common.createAndLink");
}
});
// ---- multi-part episodes: import several entries as one audiobook ----
// e.g. "100 - Toteninsel Teil 1/2/3", one title for Audible
function commonPrefix(names) {
let prefix = names[0];
for (const n of names.slice(1)) {
let i = 0;
while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++;
prefix = prefix.slice(0, i);
}
// drop a dangling part marker
return prefix.replace(/[\s._\-–—:]*(?:teil|part|cd|disc|folge)?[\s._\-–—:]*$/i, "").trim();
}
$("#import-merge").addEventListener("click", () => {
const chosen = scanItems.filter((it) => it.checked);
if (chosen.length < 2) {
toast(t("import.needTwo"), true);
return;
}
if (new Set(chosen.map((it) => it.media_type)).size > 1) {
toast(t("import.sameTypeOnly"), true);
return;
}
const parents = new Set(chosen.map((it) => it.rel_dir));
if (parents.size > 1) {
toast(t("import.differentFolders"), true);
return;
}
// "A - Sphinx", "B - Volk" share no prefix, then the folder carries the title
const parentName = (chosen[0].rel_dir || "").split("/").filter(Boolean).pop();
const name =
commonPrefix(chosen.map((it) => it.name)) || parentName || chosen[0].name;
const merged = {
path: chosen[0].dir || chosen[0].path,
dir: chosen[0].dir,
name,
rel_dir: chosen[0].rel_dir,
media_type: chosen[0].media_type,
is_dir: true,
files: chosen.flatMap((it) => it.files),
formats: [...new Set(chosen.flatMap((it) => it.formats || []))].sort(),
size: chosen.reduce((n, it) => n + (it.size || 0), 0),
checked: true,
request_id: chosen.find((it) => it.request_id)?.request_id ?? null,
suggested_request_id: chosen.find((it) => it.suggested_request_id)?.suggested_request_id ?? null,
score: Math.max(...chosen.map((it) => it.score ?? 0)),
parts: chosen,
};
const first = scanItems.indexOf(chosen[0]);
scanItems = scanItems.filter((it) => !chosen.includes(it));
scanItems.splice(first, 0, merged);
toast(t("import.mergedN", { n: chosen.length, name, files: merged.files.length }));
renderImportTable();
});
function splitItem(i) {
const item = scanItems[i];
if (item.parts) { // undo a merge
scanItems.splice(i, 1, ...item.parts);
} else if (item.is_dir && item.files.length > 1) {
// one book per file: every file becomes its own entry
const parts = item.files.map((f) => ({
path: f,
name: f.split("/").pop().replace(/\.[^.]+$/, ""),
rel_dir: item.rel_dir ? `${item.rel_dir}/${item.name}` : item.name,
media_type: item.media_type,
is_dir: false,
files: [f],
formats: extOf(f),
checked: false,
request_id: null,
suggested_request_id: null,
score: null,
}));
scanItems.splice(i, 1, ...parts);
toast(t("import.splitN", { name: item.name, n: parts.length }));
} else {
return;
}
renderImportTable();
}
// ---- quick request from a scanned file ----
let quickItemIndex = null;
let quickEpisode = null;
let quickSeries = ""; // series read from the folder name, if the hit has none
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 };
}
// release noise: Audible treats every word of a query as required, so "6CD",
// "vorgelesen" or a year answers a perfectly good folder name with nothing
const RELEASE_TAG_RE = /\b(?:mp3cd|mp3|m4b|flac|ogg|opus|epub|pdf|azw3|cbz|cbr|retail|unabridged|ungek(?:ue|ü)rzt|gek(?:ue|ü)rzt|vorgelesen|abook|audiobook|h(?:oe|ö)rbuch|\d+\s?cds?|\d+\s?mp|(?:19|20)\d{2}|kompl.*)\b/gi;
// "Grant County 01", "Georgia-Serie 11", "017": a trailing number is the volume
const SERIES_VOLUME_RE = /^(.*?)[\s._-]*(?:reihe|serie)?[\s._-]*(\d{1,3})$/i;
// "Folge 124", "Band 3": a counter word in front of the number is not a series
const COUNTER_WORD_RE = /^(?:folge|folgen|teil|band|nr\.?|nummer|episode|ep|vol|volume|track|cd|disc)$/i;
// a language tag from the release name; uppercase only, so a Spanish "de" stays
const LANG_TAG_RE = /\b(?:DE|EN|GER|ENG|MULTI)\b/g;
// "Karin Slaughter", "J R R Tolkien" - every word capitalised, no digits, so a
// series like "Die drei ???" is not mistaken for an author
const PERSON_RE = /^\p{Lu}[\p{L}.'’-]*(?: \p{Lu}[\p{L}.'’-]*){1,3}$/u;
function stripTags(name) {
return name
.replace(LANG_TAG_RE, " ")
.replace(RELEASE_TAG_RE, " ")
.replace(/\s+/g, " ")
.trim();
}
function splitSeriesVolume(part) {
const m = SERIES_VOLUME_RE.exec(part.trim());
if (!m) return null;
const head = m[1].trim();
return { series: COUNTER_WORD_RE.test(head) ? "" : head, volume: parseInt(m[2], 10) };
}
function cleanFileName(name) {
return stripTags(name.replace(/[._\-\[\]()]+/g, " "));
}
// Download folders follow a few scene conventions: "Autor - Serie 03 - Titel",
// "Serie - 017 - Titel", "Autor - Titel (Serie 7)", "Autor_-_Titel-6CD-2013".
// The series number and the tags must stay out of the search query, but the
// leading segment must stay in it - for "Die drei ???" it is what Audible
// titles the books after.
function parseDownloadName(name) {
let series = "", volume = null, authors = "", lead = "";
let s = name.replace(/_/g, " ").replace(/(\p{L})\.(?=\p{L})/gu, "$1 ");
// "(Georgia 7)" names the series, "(ADAC Motorwelt Edition)" is noise
s = s.replace(/[[(]([^)\]]*)[)\]]/g, (_, inner) => {
const sv = splitSeriesVolume(stripTags(inner));
if (sv) {
series = sv.series || series;
volume = sv.volume;
}
return " ";
});
const parts = stripTags(s)
.split(/\s+[-–—]+\s+/)
.map((p) => p.replace(/^[\s.\-–—]+|[\s.\-–—]+$/g, ""))
.filter(Boolean);
if (parts.length >= 3) {
const sv = splitSeriesVolume(parts[1]);
if (sv) {
volume = sv.volume;
// a named middle part leaves the first one as the author, a bare number
// ("Serie - 017 - Titel") as the series
if (sv.series) {
series = sv.series;
authors = parts[0];
} else {
series = parts[0];
}
lead = parts.splice(0, 2)[0];
}
} else if (parts.length === 2) {
// "Die drei ??? 017 - Titel": series and number share the first part
const sv = splitSeriesVolume(parts[0]);
if (sv && sv.series) {
series = sv.series;
volume = sv.volume;
lead = sv.series;
parts.shift();
}
}
if (!lead && parts.length > 1 && PERSON_RE.test(parts[0])) {
authors = lead = parts.shift();
}
let rest = parts.join(" - ");
if (volume == null) {
const ep = splitEpisodeNumber(rest);
if (ep.number != null) {
volume = ep.number;
rest = ep.rest;
}
}
const title = cleanFileName(rest) || cleanFileName(name) || name;
return { title, volume, series, authors, query: cleanFileName(`${lead} ${rest}`) || title };
}
function openQuickDialog(i) {
quickItemIndex = i;
const item = scanItems[i];
// the number wrecks the Audible search, so it becomes the volume instead
const parsed = parseDownloadName(item.name);
quickEpisode = parsed.volume;
quickSeries = parsed.series;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
$("#quick-episode").textContent =
parsed.volume != null ? t("quick.episodeNote", { n: parsed.volume }) : "";
$("#quick-q").value = parsed.query;
quickPicked = null;
$("#quick-req-filter").value = "";
$("#quick-m-title").value = parsed.title;
$("#quick-m-authors").value = parsed.authors;
$("#quick-m-volume").value = parsed.volume ?? "";
$("#quick-m-series").value = parsed.series || lastSeries;
$("#quick-based-on").hidden = true;
$("#quick-manual-summary").textContent = t("quick.manualSummary");
$("#quick-manual").open = false;
$("#quick-library").innerHTML = libOptions(item.media_type) || noLibraryOption();
$("#quick-results").innerHTML = "";
renderQuickOpenRequests(item);
$("#quick-dialog").showModal();
quickAutoSearch = true; // opening the dialog searches Audible only
runQuickSearch();
}
// link to an open request instead of creating a duplicate: guesses from the
// file name, or a search over all open requests once something is typed
const QUICK_OPEN_LIMIT = 20;
function renderQuickOpenRequests(item) {
const box = $("#quick-open-requests");
const query = $("#quick-req-filter").value.trim().toLowerCase();
const open = missingReqs.filter((r) => r.media_type === item.media_type);
const hay = (r) => `${r.title} ${r.authors} ${r.series} ${r.narrator} ${r.volume ?? ""}`.toLowerCase();
let matches;
let heading;
if (query) {
const terms = query.split(/\s+/);
matches = open.filter((r) => terms.every((t) => hay(r).includes(t)));
heading = matches.length
? t("quick.hits", { shown: Math.min(matches.length, QUICK_OPEN_LIMIT),
total: matches.length, open: open.length })
: t("quick.noHits", { open: open.length });
matches = matches.slice(0, QUICK_OPEN_LIMIT);
} else {
const tokens = cleanFileName(item.name).toLowerCase().split(" ").filter((t) => t.length > 2);
matches = open
.map((r) => ({ r, hits: tokens.filter((t) => hay(r).includes(t)).length }))
.filter((m) => m.hits >= 2)
.sort((a, b) => b.hits - a.hits)
.slice(0, 3)
.map((m) => m.r);
heading = matches.length ? t("quick.matchingOpen") : "";
}
box.innerHTML =
(heading ? `<p class="muted">${esc(heading)}</p>` : "") +
matches
.map(
(r) => `<div class="row open-req">
<span>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""} — ${esc(r.authors)} (${esc(r.library_name)})</span>
<button class="secondary" data-link-req="${r.id}">${t("common.link")}</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(t("quick.linked"));
})
);
}
async function runQuickSearch() {
const item = scanItems[quickItemIndex];
const box = $("#quick-results");
box.innerHTML = `<p class='muted'>${t("quick.searching")}</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'>${esc(t(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 ?? "",
languageName(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="${esc(t("quick.editTitle"))}">✎</button>
<button data-pick="${j}" title="${esc(t("quick.pickTitle"))}">${t("common.link")}</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(t("common.needLibraryFirst"), true); return; }
const i = quickItemIndex;
try {
const req = await createRequest({ library_id: parseInt(libId), ...body });
setLastLib(scanItems[i].media_type, libId);
missingReqs.push(req);
scanItems[i].request_id = req.id;
scanItems[i].checked = true;
renderImportTable();
refreshMissingBadge();
$("#quick-dialog").close();
toast(t("quick.requestedAndLinked", { title: req.title }));
} catch (err) { toast(err.message, true); }
}
function pickQuickResult(r) {
return requestAndConnect({
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id, volume: r.volume ?? quickEpisode,
year: r.year, series: r.series || quickSeries, 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 || quickSeries;
$("#quick-m-volume").value = r.volume ?? quickEpisode ?? "";
$("#quick-based-on").textContent = t("quick.basedOn", { title: r.title });
$("#quick-based-on").hidden = false;
$("#quick-manual-summary").textContent = t("quick.editSummary");
$("#quick-manual").open = true;
$("#quick-m-title").focus();
}
$("#quick-m-submit").addEventListener("click", () => {
const title = $("#quick-m-title").value.trim();
if (!title) { toast(t("quick.needTitle"), true); return; }
const volume = $("#quick-m-volume").value;
const base = quickPicked
? { narrator: quickPicked.narrator || "", external_id: quickPicked.external_id,
year: quickPicked.year, cover_url: quickPicked.cover_url }
: {};
return requestAndConnect({
...base,
title,
authors: $("#quick-m-authors").value.trim(),
series: (lastSeries = $("#quick-m-series").value.trim()),
volume: volume ? parseInt(volume) : null,
});
});
// ---- import execution (batched, with progress) ----
const IMPORT_BATCH_SIZE = 20;
// finished rows disappear after a while, the result list keeps the record
const IMPORT_DONE_CLEANUP_MS = 15000;
let cleanupTimer = null;
// several entries on one request: one audiobook split across folders
function groupByRequest(chosen) {
const byRequest = new Map();
for (const item of chosen) {
const group = byRequest.get(item.request_id);
if (group) group.push(item);
else byRequest.set(item.request_id, [item]);
}
return [...byRequest.values()];
}
function buildImportGroups(groups) {
return groups.map((group) => ({
parts: group,
item: {
path: group.length > 1 ? sharedParent(group) : group[0].path,
is_dir: group.length > 1 ? true : group[0].is_dir,
files: group.flatMap((it) => it.files),
request_id: group[0].request_id,
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
},
}));
}
// either a multi-part title (merge) or a mismatch (skip), decided once
function askAboutSharedRequests(shared) {
const dlg = $("#conflict-dialog");
$("#conflict-intro").textContent = t("conflict.intro", { n: shared.length });
$("#conflict-list").innerHTML = shared
.map((group) => {
const req = missingReqs.find((r) => r.id === group[0].request_id);
return `<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(t("import.nothingSelected"), true); return; }
const groups = await collapseSharedRequests(chosen);
if (!groups) return;
if (!groups.length) {
$("#import-summary").innerHTML = `<p class="error">${esc(t("import.nothingImported"))}</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>` +
esc(t("import.summary", { n: allResults.length - failed.length })) +
(failed.length ? esc(t("import.summaryFailed", { n: failed.length })) : "") +
`</strong></p>` +
(skippedConflicts
? `<p class="error">${esc(t("import.summarySkipped", { n: skippedConflicts }))}</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 ? t("import.finishedFailed", { n: failed.length }) : t("import.finished"),
failed.length > 0);
refreshMissingBadge();
clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => {
// failures and conflicts stay
const before = scanItems.length;
scanItems = scanItems.filter((it) => it.importState !== "done");
if (scanItems.length !== before) {
importPage = 0;
setImportProgress(0, 0);
renderImportTable();
$("#scan-info").textContent = t("import.remaining", { n: scanItems.length });
}
}, IMPORT_DONE_CLEANUP_MS);
// the list stays: rescanning would wipe the feedback
} finally {
btn.disabled = false;
btn.textContent = t("import.run");
}
});
function setImportProgress(done, total) {
const box = $("#import-progress");
if (!total) { box.hidden = true; return; }
if (done >= total) {
box.hidden = done < total;
if (done >= total) {
box.querySelector("progress").value = total;
box.querySelector("span").textContent = t("import.progressDone", { total });
}
return;
}
box.hidden = false;
const bar = box.querySelector("progress");
bar.max = total;
bar.value = done;
box.querySelector("span").textContent =
`${done}/${total} · ${Math.round((done / total) * 100)} %`;
}
// ---- language ----
function updateLangButton() {
$("#lang-toggle").textContent = lang === "de" ? "EN" : "DE";
}
$("#lang-toggle").addEventListener("click", () => setLang(lang === "de" ? "en" : "de"));
// the static labels are handled by applyI18n, everything rendered from data is not
function onLangChange() {
updateLangButton();
renderSearchResults();
renderRequests("missing");
renderRequests("imported");
if (libraries.length) loadLibraries();
const editId = $("#lib-form").dataset.editId;
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
if (scanItems.length) renderImportTable();
}
// ---- version ----
// the build stamp is the quickest answer to "did the redeploy arrive?"
async function showVersion() {
try {
const v = await api("/api/version");
// the version alone rarely moves, so the stamp is what tells deploys apart
$("#app-version").textContent = `v${v.version} · ${v.build || v.built_at}`;
$("#app-version").title = `${v.built_at} UTC${v.build ? " · " + v.build : ""}`;
} catch {}
}
// ---- init ----
applyI18n();
updateLangButton();
endLibraryEdit();
loadLibraries();
refreshMissingBadge();
showVersion();