1934 lines
72 KiB
JavaScript
1934 lines
72 KiB
JavaScript
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;
|
||
}
|
||
}
|
||
|
||
let toastTimer;
|
||
|
||
function toast(msg, isError = false) {
|
||
const t = $("#toast");
|
||
t.textContent = msg;
|
||
t.className = isError ? "error" : "";
|
||
t.hidden = false;
|
||
// an older toast's timer would otherwise hide this one early
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => (t.hidden = true), 4000);
|
||
}
|
||
|
||
// quotes included: the result also lands inside attributes (value="${esc(...)}")
|
||
const ESC_CHARS = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||
|
||
function esc(s) {
|
||
return String(s ?? "").replace(/[&<>"']/g, (c) => ESC_CHARS[c]);
|
||
}
|
||
|
||
// last used library per media type
|
||
function getLastLib(type) {
|
||
try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; }
|
||
}
|
||
function setLastLib(type, id) {
|
||
try { localStorage.setItem("wordarr.lastLib." + type, String(id)); } catch {}
|
||
}
|
||
function noLibraryOption() {
|
||
return `<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 { count } = await api("/api/requests/count?status=missing");
|
||
const badge = $("#missing-badge");
|
||
badge.textContent = count;
|
||
badge.hidden = count === 0;
|
||
} catch {}
|
||
}
|
||
|
||
// ---- navigation ----
|
||
// the view lives in the hash so reload, back button and links keep it
|
||
const VIEWS = ["search", "missing", "imported", "import", "activity", "settings"];
|
||
|
||
function showView() {
|
||
const hash = location.hash.slice(1);
|
||
const name = VIEWS.includes(hash) ? hash : "search";
|
||
$$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
|
||
if (name === "missing" || name === "imported") loadRequests(name);
|
||
if (name === "activity") loadHistory();
|
||
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
|
||
}
|
||
|
||
$$("nav button[data-view]").forEach((btn) =>
|
||
btn.addEventListener("click", () => {
|
||
// same hash fires no hashchange, but a click should still reload the list
|
||
if (location.hash === "#" + btn.dataset.view) showView();
|
||
else location.hash = btn.dataset.view;
|
||
})
|
||
);
|
||
window.addEventListener("hashchange", showView);
|
||
|
||
// ---- 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="secondary" data-adopt-lib="${l.id}" title="${esc(t("adopt.buttonTitle"))}">${t("adopt.button")}</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-adopt-lib]").forEach((b) =>
|
||
b.addEventListener("click", () => openAdopt(parseInt(b.dataset.adoptLib)))
|
||
);
|
||
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());
|
||
}
|
||
|
||
// the server already sends newest first
|
||
const SORTS = {
|
||
title: (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }),
|
||
author: (a, b) => a.authors.localeCompare(b.authors) || SORTS.title(a, b),
|
||
series: (a, b) => (a.series || a.title).localeCompare(b.series || b.title)
|
||
|| (a.volume ?? 0) - (b.volume ?? 0) || SORTS.title(a, b),
|
||
};
|
||
for (const status of ["missing", "imported"]) {
|
||
const sel = $(`#${status}-sort`);
|
||
try { sel.value = localStorage.getItem("wordarr.sort." + status) || "newest"; } catch {}
|
||
sel.addEventListener("change", () => {
|
||
try { localStorage.setItem("wordarr.sort." + status, sel.value); } catch {}
|
||
renderRequests(status);
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||
if (!currentRequests[status].length) {
|
||
box.innerHTML = `<p class="muted"><span class="spinner"></span> ${t("common.loading")}</p>`;
|
||
}
|
||
try {
|
||
currentRequests[status] = await api("/api/requests?" + params);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
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 sort = SORTS[$(`#${status}-sort`).value];
|
||
if (sort) reqs.sort(sort);
|
||
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
|
||
const moveSel = status === "missing" ? $("#missing-move-library") : null;
|
||
if (!reqs.length) {
|
||
box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`;
|
||
bulkBtn.hidden = true;
|
||
if (moveSel) moveSel.hidden = true;
|
||
return;
|
||
}
|
||
box.innerHTML = reqs
|
||
.map(
|
||
(r) => `<div class="card clickable" data-detail="${r.id}" tabindex="0">
|
||
${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);
|
||
})
|
||
);
|
||
box.querySelectorAll("[data-detail]").forEach((card) =>
|
||
card.addEventListener("keydown", (ev) => {
|
||
if (ev.target !== card || (ev.key !== "Enter" && ev.key !== " ")) return;
|
||
ev.preventDefault();
|
||
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 });
|
||
if (!moveSel) return;
|
||
moveSel.hidden = n === 0;
|
||
moveSel.innerHTML = `<option value="">${esc(t("list.moveTo", { n }))}</option>` +
|
||
libraries.map((l) => `<option value="${l.id}">${esc(l.name)} (${esc(l.media_type)})</option>`).join("");
|
||
};
|
||
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;
|
||
try {
|
||
const res = await api("/api/requests/bulk", {
|
||
method: "DELETE", body: JSON.stringify({ ids: ids.map(Number) }),
|
||
});
|
||
toast(t("list.deletedN", { n: res.deleted }));
|
||
} catch (err) { toast(err.message, true); }
|
||
loadRequests(status);
|
||
}
|
||
|
||
$("#missing-move-library").addEventListener("change", async (ev) => {
|
||
const lib = libraries.find((l) => String(l.id) === ev.target.value);
|
||
if (!lib) return;
|
||
const ids = [...$("#missing-list").querySelectorAll("[data-sel-req]:checked")]
|
||
.map((c) => Number(c.dataset.selReq));
|
||
try {
|
||
const res = await api("/api/requests/bulk", {
|
||
method: "PUT", body: JSON.stringify({ ids, library_id: lib.id }),
|
||
});
|
||
toast(t("list.movedN", { n: res.updated, library: lib.name }));
|
||
} catch (err) { toast(err.message, true); }
|
||
loadRequests("missing");
|
||
});
|
||
|
||
$("#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"));
|
||
|
||
// ---- activity ----
|
||
let historyRows = [];
|
||
const HISTORY_STATE = { imported: "done", appended: "done", adopted: "done", failed: "failed" };
|
||
|
||
async function loadHistory() {
|
||
try {
|
||
historyRows = await api("/api/history");
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
renderHistory();
|
||
}
|
||
|
||
function renderHistory() {
|
||
const tbody = $("#history-table tbody");
|
||
if (!historyRows.length) {
|
||
tbody.innerHTML = `<tr><td colspan="4" class="muted">${t("common.nothingHere")}</td></tr>`;
|
||
return;
|
||
}
|
||
const locale = lang === "de" ? "de-DE" : "en-GB";
|
||
tbody.innerHTML = historyRows
|
||
.map((h) => {
|
||
const where = h.message || (h.source && h.dest ? `${h.source} → ${h.dest}` : h.dest || h.source);
|
||
return `<tr>
|
||
<td class="state">${esc(new Date(h.at).toLocaleString(locale))}</td>
|
||
<td class="state ${HISTORY_STATE[h.event] || ""}">${esc(t("history." + h.event))}</td>
|
||
<td>${esc(h.title)} <span class="tag fmt">${esc(h.media_type)}</span></td>
|
||
<td class="muted mono">${esc(where)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
// ---- 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("");
|
||
const override = seriesOverride(item);
|
||
const req = override && missingReqs.find((r) => r.id === item.request_id);
|
||
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>
|
||
${override
|
||
? `<span class="tag" title="${esc(t("import.folderSeriesTitle", {
|
||
series: req.series || "–", volume: req.volume ?? "–" }))}">${esc(
|
||
t("import.folderSeries", override))}</span>`
|
||
: ""}
|
||
${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()];
|
||
}
|
||
|
||
// "Autor - Georgia 01 - Titel": the release counts the series the way the
|
||
// translation does, the metadata APIs often count the original's volumes
|
||
function folderSeries(item) {
|
||
const parent = (item.rel_dir || "").split("/").pop();
|
||
for (const name of [item.name, parent]) {
|
||
const { series, volume } = parseDownloadName(name || "");
|
||
if (series && volume != null) return { series, volume };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// the folder's series when it differs from the chosen request's
|
||
function seriesOverride(item) {
|
||
const req = missingReqs.find((r) => r.id === item.request_id);
|
||
const folder = folderSeries(item);
|
||
if (!req || !folder) return null;
|
||
return folder.series === req.series && folder.volume === req.volume ? null : folder;
|
||
}
|
||
|
||
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
|
||
...(seriesOverride(group[0]) || {}),
|
||
},
|
||
}));
|
||
}
|
||
|
||
// 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)} %`;
|
||
}
|
||
|
||
// ---- adopt files already in a library ----
|
||
let adoptLib = null;
|
||
let adoptScan = null;
|
||
|
||
async function openAdopt(id) {
|
||
adoptLib = libraries.find((l) => l.id === id);
|
||
adoptScan = null;
|
||
$("#adopt-heading").textContent = t("adopt.heading", { name: adoptLib.name });
|
||
$("#adopt-intro").innerHTML = `<span class="spinner"></span> ${t("import.scanning")}`;
|
||
$("#adopt-list").innerHTML = "";
|
||
$("#adopt-submit").disabled = true;
|
||
$("#adopt-dialog").showModal();
|
||
try {
|
||
adoptScan = await api("/api/import/library/" + id);
|
||
} catch (err) {
|
||
$("#adopt-intro").textContent = "";
|
||
toast(err.message, true);
|
||
return;
|
||
}
|
||
renderAdopt();
|
||
}
|
||
|
||
function renderAdopt() {
|
||
if (!adoptScan) return;
|
||
// only files with a suggestion: a select per unmatched file would bury them
|
||
const matched = adoptScan.items.filter((i) => i.suggested_request_id);
|
||
$("#adopt-intro").textContent = t("adopt.intro", {
|
||
n: matched.length, rest: adoptScan.items.length - matched.length,
|
||
});
|
||
const options = adoptScan.requests
|
||
.map((r) => [r.id, r.authors ? `${r.authors}: ${r.title}` : r.title]);
|
||
$("#adopt-list").innerHTML = matched
|
||
.map((item) => `<label>
|
||
<input type="checkbox" checked data-adopt-path="${esc(item.path)}" />
|
||
<span>${esc(item.name)} <span class="muted">${esc(item.rel_dir)}</span></span>
|
||
<select>${options.map(([id, label]) =>
|
||
`<option value="${id}"${id === item.suggested_request_id ? " selected" : ""}>${esc(label)}</option>`
|
||
).join("")}</select>
|
||
</label>`)
|
||
.join("");
|
||
$("#adopt-submit").disabled = matched.length === 0;
|
||
}
|
||
|
||
$("#adopt-cancel").addEventListener("click", () => $("#adopt-dialog").close());
|
||
|
||
$("#adopt-submit").addEventListener("click", async () => {
|
||
const items = [...$$("#adopt-list [data-adopt-path]:checked")].map((box) => ({
|
||
path: box.dataset.adoptPath,
|
||
request_id: parseInt(box.closest("label").querySelector("select").value),
|
||
}));
|
||
if (!items.length) return;
|
||
$("#adopt-submit").disabled = true;
|
||
try {
|
||
const { results } = await api("/api/import/library/" + adoptLib.id, {
|
||
method: "POST", body: JSON.stringify({ items }),
|
||
});
|
||
const failed = results.filter((r) => !r.ok);
|
||
toast(failed.length
|
||
? t("adopt.partial", { n: results.length - failed.length, failed: failed.map((r) => `${r.path}: ${r.error}`).join("; ") })
|
||
: t("adopt.done", { n: results.length }), failed.length > 0);
|
||
$("#adopt-dialog").close();
|
||
refreshMissingBadge();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
} finally {
|
||
$("#adopt-submit").disabled = false;
|
||
}
|
||
});
|
||
|
||
// ---- 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");
|
||
renderHistory();
|
||
if (libraries.length) loadLibraries();
|
||
const editId = $("#lib-form").dataset.editId;
|
||
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
||
if (scanItems.length) renderImportTable();
|
||
renderAdopt();
|
||
}
|
||
|
||
// ---- 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();
|
||
showView();
|