985 lines
36 KiB
JavaScript
985 lines
36 KiB
JavaScript
const $ = (sel) => document.querySelector(sel);
|
||
const $$ = (sel) => document.querySelectorAll(sel);
|
||
|
||
let libraries = [];
|
||
|
||
async function api(path, opts = {}) {
|
||
const resp = await fetch(path, {
|
||
headers: { "Content-Type": "application/json" },
|
||
...opts,
|
||
});
|
||
if (!resp.ok) {
|
||
let msg = resp.statusText;
|
||
try { msg = (await resp.json()).detail || msg; } catch {}
|
||
const err = new Error(msg);
|
||
err.status = resp.status;
|
||
throw err;
|
||
}
|
||
return resp.json();
|
||
}
|
||
|
||
// create a request; on a duplicate warning (409) ask the user before retrying
|
||
async function createRequest(body) {
|
||
try {
|
||
return await api("/api/requests", { method: "POST", body: JSON.stringify(body) });
|
||
} catch (err) {
|
||
if (err.status === 409 && confirm(err.message + "\nTrotzdem anlegen?")) {
|
||
return api("/api/requests?allow_duplicate=true", {
|
||
method: "POST", body: JSON.stringify(body),
|
||
});
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function toast(msg, isError = false) {
|
||
const t = $("#toast");
|
||
t.textContent = msg;
|
||
t.className = isError ? "error" : "";
|
||
t.hidden = false;
|
||
setTimeout(() => (t.hidden = true), 4000);
|
||
}
|
||
|
||
function esc(s) {
|
||
const d = document.createElement("div");
|
||
d.textContent = s ?? "";
|
||
return d.innerHTML;
|
||
}
|
||
|
||
// last used library per media type (per-browser convenience)
|
||
function getLastLib(type) {
|
||
try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; }
|
||
}
|
||
function setLastLib(type, id) {
|
||
try { localStorage.setItem("wordarr.lastLib." + type, String(id)); } catch {}
|
||
}
|
||
function libOptions(mediaType) {
|
||
const last = getLastLib(mediaType);
|
||
return libraries
|
||
.filter((l) => l.media_type === mediaType)
|
||
.map((l) => `<option value="${l.id}" ${String(l.id) === last ? "selected" : ""}>${esc(l.name)}</option>`)
|
||
.join("");
|
||
}
|
||
|
||
// ---- missing badge ----
|
||
async function refreshMissingBadge() {
|
||
try {
|
||
const reqs = await api("/api/requests?status=missing");
|
||
const badge = $("#missing-badge");
|
||
badge.textContent = reqs.length;
|
||
badge.hidden = reqs.length === 0;
|
||
} catch {}
|
||
}
|
||
|
||
// ---- navigation ----
|
||
$$("nav button").forEach((btn) =>
|
||
btn.addEventListener("click", () => {
|
||
$$("nav button").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
$$("main > section").forEach((s) => (s.hidden = true));
|
||
$("#view-" + btn.dataset.view).hidden = false;
|
||
if (btn.dataset.view === "missing") loadRequests("missing");
|
||
if (btn.dataset.view === "imported") loadRequests("imported");
|
||
if (btn.dataset.view === "settings") loadLibraries();
|
||
})
|
||
);
|
||
|
||
// ---- libraries ----
|
||
async function loadLibraries() {
|
||
libraries = await api("/api/libraries");
|
||
const tbody = $("#lib-table tbody");
|
||
tbody.innerHTML = libraries
|
||
.map(
|
||
(l) => `<tr>
|
||
<td>${esc(l.name)}</td><td>${esc(l.media_type)}</td><td>${esc(l.root_path)}</td>
|
||
<td class="mono">${esc(l.folder_template)}</td><td class="mono">${esc(l.file_template)}</td>
|
||
<td class="row">
|
||
<button class="secondary" data-edit-lib="${l.id}">Bearbeiten</button>
|
||
<button class="danger" data-del-lib="${l.id}">Löschen</button>
|
||
</td>
|
||
</tr>`
|
||
)
|
||
.join("");
|
||
tbody.querySelectorAll("[data-edit-lib]").forEach((b) =>
|
||
b.addEventListener("click", () => startLibraryEdit(parseInt(b.dataset.editLib)))
|
||
);
|
||
tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
if (!confirm("Library löschen?")) return;
|
||
try {
|
||
await api("/api/libraries/" + b.dataset.delLib, { method: "DELETE" });
|
||
loadLibraries();
|
||
} catch (e) { toast(e.message, true); }
|
||
})
|
||
);
|
||
const filter = $("#missing-filter-library");
|
||
filter.innerHTML =
|
||
'<option value="">Alle Libraries</option>' +
|
||
libraries.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join("");
|
||
}
|
||
|
||
function startLibraryEdit(id) {
|
||
const lib = libraries.find((l) => l.id === id);
|
||
if (!lib) return;
|
||
const form = $("#lib-form");
|
||
form.name.value = lib.name;
|
||
form.media_type.value = lib.media_type;
|
||
form.root_path.value = lib.root_path;
|
||
form.folder_template.value = lib.folder_template;
|
||
form.file_template.value = lib.file_template;
|
||
form.dataset.editId = lib.id;
|
||
$("#lib-form-title").textContent = `Library „${lib.name}“ bearbeiten`;
|
||
$("#lib-form-submit").textContent = "Speichern";
|
||
$("#lib-form-cancel").hidden = false;
|
||
form.scrollIntoView({ behavior: "smooth" });
|
||
}
|
||
|
||
function endLibraryEdit() {
|
||
const form = $("#lib-form");
|
||
form.reset();
|
||
delete form.dataset.editId;
|
||
$("#lib-form-title").textContent = "Neue Library";
|
||
$("#lib-form-submit").textContent = "Anlegen";
|
||
$("#lib-form-cancel").hidden = true;
|
||
}
|
||
$("#lib-form-cancel").addEventListener("click", endLibraryEdit);
|
||
|
||
$("#lib-form").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const data = Object.fromEntries(new FormData(e.target));
|
||
if (!data.folder_template) delete data.folder_template;
|
||
if (!data.file_template) delete data.file_template;
|
||
const editId = e.target.dataset.editId;
|
||
try {
|
||
if (editId) {
|
||
await api("/api/libraries/" + editId, { method: "PUT", body: JSON.stringify(data) });
|
||
toast("Library gespeichert — gilt für künftige Importe");
|
||
} else {
|
||
await api("/api/libraries", { method: "POST", body: JSON.stringify(data) });
|
||
toast("Library angelegt");
|
||
}
|
||
endLibraryEdit();
|
||
loadLibraries();
|
||
} catch (err) { toast(err.message, true); }
|
||
});
|
||
|
||
// path autocompletion for the library form
|
||
{
|
||
const pathInput = document.querySelector('#lib-form [name=root_path]');
|
||
const datalist = $("#path-suggestions");
|
||
let pathTimer;
|
||
pathInput.addEventListener("input", () => {
|
||
clearTimeout(pathTimer);
|
||
const value = pathInput.value;
|
||
if (!value.startsWith("/")) return;
|
||
pathTimer = setTimeout(async () => {
|
||
try {
|
||
const res = await api("/api/fs/browse?path=" + encodeURIComponent(value));
|
||
datalist.innerHTML = res.dirs.map((d) => `<option value="${esc(d)}">`).join("");
|
||
} catch {}
|
||
}, 150);
|
||
});
|
||
}
|
||
|
||
// ---- search & request ----
|
||
const EMPTY_HINTS = {
|
||
ebook: "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen.",
|
||
audiobook: "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.",
|
||
comic: "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen“ erfassen.",
|
||
};
|
||
|
||
$("#search-type").addEventListener("change", () => ($("#search-results").innerHTML = ""));
|
||
|
||
$("#search-form").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const type = $("#search-type").value;
|
||
const q = $("#search-q").value;
|
||
const box = $("#search-results");
|
||
const searchBtn = $("#search-btn");
|
||
searchBtn.disabled = true;
|
||
searchBtn.innerHTML = '<span class="spinner"></span> Suche…';
|
||
try {
|
||
const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`);
|
||
if (!results.length) {
|
||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[type]}</p>`;
|
||
return;
|
||
}
|
||
const opts = libOptions(type);
|
||
box.innerHTML = results
|
||
.map(
|
||
(r, i) => `<div class="card">
|
||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||
<div class="card-body">
|
||
<strong>${esc(r.title)}</strong>
|
||
<span>${esc(r.authors)}</span>
|
||
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
|
||
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
|
||
<span class="muted">${r.year ?? ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
|
||
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
|
||
<div class="row">
|
||
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
|
||
<button data-req="${i}">Anfragen</button>
|
||
${r.series_id ? `<button class="secondary" data-series="${i}">Ganze Serie…</button>` : ""}
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
box.querySelectorAll("[data-series]").forEach((btn) =>
|
||
btn.addEventListener("click", () => openSeriesDialog(results[btn.dataset.series]))
|
||
);
|
||
box.querySelectorAll("[data-req]").forEach((btn) =>
|
||
btn.addEventListener("click", async () => {
|
||
const i = btn.dataset.req;
|
||
const r = results[i];
|
||
const libId = box.querySelector(`[data-lib="${i}"]`).value;
|
||
if (!libId) { toast("Erst eine Library für diesen Typ anlegen (Tab Libraries)", true); return; }
|
||
const volInput = box.querySelector(`[data-vol="${i}"]`);
|
||
try {
|
||
await createRequest({
|
||
library_id: parseInt(libId),
|
||
title: r.title, authors: r.authors, narrator: r.narrator || "",
|
||
external_id: r.external_id,
|
||
year: r.year, series: r.series, cover_url: r.cover_url,
|
||
volume: volInput && volInput.value ? parseInt(volInput.value) : (r.volume ?? null),
|
||
});
|
||
setLastLib(type, libId);
|
||
btn.textContent = "✓ Angefragt";
|
||
btn.disabled = true;
|
||
refreshMissingBadge();
|
||
} catch (err) { toast(err.message, true); }
|
||
})
|
||
);
|
||
} catch (err) {
|
||
box.innerHTML = "";
|
||
toast(err.message, true);
|
||
} finally {
|
||
searchBtn.disabled = false;
|
||
searchBtn.textContent = "Suchen";
|
||
}
|
||
});
|
||
|
||
// ---- request a whole series ----
|
||
let seriesEpisodes = [];
|
||
|
||
function renderSeriesList() {
|
||
const from = parseInt($("#series-from").value);
|
||
const to = parseInt($("#series-to").value);
|
||
const inRange = (e) =>
|
||
(isNaN(from) || (e.volume != null && e.volume >= from)) &&
|
||
(isNaN(to) || (e.volume != null && e.volume <= to));
|
||
let previous = null;
|
||
$("#series-list").innerHTML = seriesEpisodes
|
||
.map((e, i) => {
|
||
// make holes in the numbering visible - the crawl cannot reach every episode
|
||
let gap = "";
|
||
if (e.volume != null && previous != null && e.volume > previous + 1) {
|
||
const missing = e.volume - previous - 1;
|
||
gap = `<div class="gap">… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}–${e.volume - 1})</div>`;
|
||
}
|
||
if (e.volume != null) previous = e.volume;
|
||
return `${gap}<label>
|
||
<input type="checkbox" data-ep="${i}" ${inRange(e) ? "checked" : ""}>
|
||
<span class="num">${e.volume ?? "—"}</span>
|
||
<span>${esc(e.title)}</span>
|
||
</label>`;
|
||
})
|
||
.join("");
|
||
updateSeriesCount();
|
||
}
|
||
|
||
function seriesChecked() {
|
||
return [...$("#series-list").querySelectorAll("input:checked")].map(
|
||
(c) => seriesEpisodes[c.dataset.ep]
|
||
);
|
||
}
|
||
|
||
function updateSeriesCount() {
|
||
const n = seriesChecked().length;
|
||
const btn = $("#series-submit");
|
||
btn.textContent = n ? `${n} anfragen` : "Anfragen";
|
||
btn.disabled = n === 0;
|
||
}
|
||
|
||
async function openSeriesDialog(result) {
|
||
const dlg = $("#series-dialog");
|
||
$("#series-title").textContent = result.series || "Serie";
|
||
$("#series-info").innerHTML = '<span class="spinner"></span> Folgen werden gesucht…';
|
||
$("#series-list").innerHTML = "";
|
||
$("#series-from").value = "";
|
||
$("#series-to").value = "";
|
||
$("#series-library").innerHTML = libOptions("audiobook") || "<option value=''>— keine Library —</option>";
|
||
$("#series-submit").disabled = true;
|
||
dlg.showModal();
|
||
try {
|
||
seriesEpisodes = await api(
|
||
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
|
||
`&title=${encodeURIComponent(result.series || result.title)}`
|
||
);
|
||
} catch (err) {
|
||
$("#series-info").textContent = err.message;
|
||
return;
|
||
}
|
||
const numbered = seriesEpisodes.filter((e) => e.volume != null);
|
||
$("#series-info").textContent =
|
||
`${seriesEpisodes.length} Folgen gefunden` +
|
||
(numbered.length ? ` (Nr. ${numbered[0].volume}–${numbered[numbered.length - 1].volume})` : "") +
|
||
". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.";
|
||
renderSeriesList();
|
||
}
|
||
|
||
$("#series-from").addEventListener("input", renderSeriesList);
|
||
$("#series-to").addEventListener("input", renderSeriesList);
|
||
$("#series-list").addEventListener("change", updateSeriesCount);
|
||
$("#series-all").addEventListener("click", () => {
|
||
$("#series-list").querySelectorAll("input").forEach((c) => (c.checked = true));
|
||
updateSeriesCount();
|
||
});
|
||
$("#series-none").addEventListener("click", () => {
|
||
$("#series-list").querySelectorAll("input").forEach((c) => (c.checked = false));
|
||
updateSeriesCount();
|
||
});
|
||
$("#series-cancel").addEventListener("click", () => $("#series-dialog").close());
|
||
|
||
$("#series-submit").addEventListener("click", async () => {
|
||
const libId = $("#series-library").value;
|
||
if (!libId) { toast("Erst eine Audiobook-Library anlegen (Tab Libraries)", true); return; }
|
||
const items = seriesChecked().map((e) => ({
|
||
title: e.title, authors: e.authors, narrator: e.narrator || "",
|
||
external_id: e.external_id, year: e.year, series: e.series,
|
||
volume: e.volume, cover_url: e.cover_url,
|
||
}));
|
||
const btn = $("#series-submit");
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Lege an…';
|
||
try {
|
||
const res = await api("/api/requests/bulk-items", {
|
||
method: "POST",
|
||
body: JSON.stringify({ library_id: parseInt(libId), items }),
|
||
});
|
||
setLastLib("audiobook", libId);
|
||
toast(
|
||
`${res.created.length} Anfragen angelegt` +
|
||
(res.skipped ? `, ${res.skipped} bereits vorhanden` : "")
|
||
);
|
||
$("#series-dialog").close();
|
||
refreshMissingBadge();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
updateSeriesCount();
|
||
}
|
||
});
|
||
|
||
// ---- manual request ----
|
||
$("#manual-btn").addEventListener("click", () => {
|
||
const dlg = $("#manual-dialog");
|
||
const typeSel = dlg.querySelector("[name=media_type]");
|
||
const updateLibs = () => {
|
||
$("#manual-library").innerHTML = libOptions(typeSel.value) || "<option value=''>— keine Library —</option>";
|
||
};
|
||
typeSel.onchange = updateLibs;
|
||
updateLibs();
|
||
dlg.showModal();
|
||
});
|
||
|
||
$("#manual-form").addEventListener("submit", async (e) => {
|
||
if (e.submitter && e.submitter.value === "cancel") return;
|
||
const data = Object.fromEntries(new FormData(e.target));
|
||
if (!data.library_id) { toast("Keine Library gewählt", true); return; }
|
||
try {
|
||
if (data.volume_to) {
|
||
if (!data.volume) {
|
||
toast("Für Bulk bitte Start-Band angeben", true);
|
||
return;
|
||
}
|
||
const created = await api("/api/requests/bulk", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
library_id: parseInt(data.library_id),
|
||
series: data.series || data.title, authors: data.authors,
|
||
narrator: data.narrator || "",
|
||
volume_from: parseInt(data.volume),
|
||
volume_to: parseInt(data.volume_to),
|
||
year: data.year ? parseInt(data.year) : null,
|
||
}),
|
||
});
|
||
toast(`${created.length} Anfragen angelegt`);
|
||
} else {
|
||
await createRequest({
|
||
library_id: parseInt(data.library_id),
|
||
title: data.title, authors: data.authors, series: data.series,
|
||
narrator: data.narrator || "",
|
||
external_id: data.external_id,
|
||
year: data.year ? parseInt(data.year) : null,
|
||
volume: data.volume ? parseInt(data.volume) : null,
|
||
});
|
||
toast("Anfrage angelegt");
|
||
}
|
||
setLastLib(data.media_type, data.library_id);
|
||
e.target.reset();
|
||
refreshMissingBadge();
|
||
} catch (err) { toast(err.message, true); }
|
||
});
|
||
|
||
// ---- missing / imported lists ----
|
||
let currentRequests = { missing: [], imported: [] };
|
||
|
||
// view mode (Liste / kleine / große Symbole), shared by both lists
|
||
function getViewMode() {
|
||
try { return localStorage.getItem("wordarr.viewMode") || "small"; } catch { return "small"; }
|
||
}
|
||
function applyViewMode(mode) {
|
||
try { localStorage.setItem("wordarr.viewMode", mode); } catch {}
|
||
["#missing-list", "#imported-list"].forEach((sel) => {
|
||
const box = $(sel);
|
||
box.classList.remove("view-list", "view-small", "view-large");
|
||
box.classList.add("view-" + mode);
|
||
});
|
||
$$(".view-toggle button").forEach((b) =>
|
||
b.classList.toggle("active", b.dataset.viewmode === mode)
|
||
);
|
||
}
|
||
$$(".view-toggle button").forEach((b) =>
|
||
b.addEventListener("click", () => applyViewMode(b.dataset.viewmode))
|
||
);
|
||
applyViewMode(getViewMode());
|
||
|
||
function requestMatchesFilter(r, text) {
|
||
if (!text) return true;
|
||
const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase();
|
||
return hay.includes(text.toLowerCase());
|
||
}
|
||
|
||
async function loadRequests(status) {
|
||
const params = new URLSearchParams({ status });
|
||
if (status === "missing") {
|
||
const t = $("#missing-filter-type").value;
|
||
const l = $("#missing-filter-library").value;
|
||
if (t) params.set("media_type", t);
|
||
if (l) params.set("library_id", l);
|
||
}
|
||
currentRequests[status] = await api("/api/requests?" + params);
|
||
renderRequests(status);
|
||
if (status === "missing") refreshMissingBadge();
|
||
}
|
||
|
||
function renderRequests(status) {
|
||
const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value;
|
||
const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text));
|
||
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
|
||
if (!reqs.length) {
|
||
box.innerHTML = "<p class='muted'>Nichts hier.</p>";
|
||
bulkBtn.hidden = true;
|
||
return;
|
||
}
|
||
box.innerHTML = reqs
|
||
.map(
|
||
(r) => `<div class="card clickable" data-detail="${r.id}">
|
||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||
<div class="card-body">
|
||
<strong>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}</strong>
|
||
<span>${esc(r.authors)}</span>
|
||
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
|
||
<span class="muted">${esc(r.media_type)} → ${esc(r.library_name)}</span>
|
||
${status === "imported" ? `<span class="muted mono">${esc(r.imported_path)}</span>` : ""}
|
||
</div>
|
||
<input type="checkbox" class="card-check" data-sel-req="${r.id}">
|
||
</div>`
|
||
)
|
||
.join("");
|
||
box.querySelectorAll("[data-detail]").forEach((card) =>
|
||
card.addEventListener("click", (ev) => {
|
||
if (ev.target.closest("input,button,select,a")) return;
|
||
openDetail(parseInt(card.dataset.detail), status);
|
||
})
|
||
);
|
||
const updateBulkBtn = () => {
|
||
const n = box.querySelectorAll("[data-sel-req]:checked").length;
|
||
bulkBtn.hidden = n === 0;
|
||
bulkBtn.textContent = `Ausgewählte entfernen (${n})`;
|
||
};
|
||
box.querySelectorAll("[data-sel-req]").forEach((c) =>
|
||
c.addEventListener("change", updateBulkBtn)
|
||
);
|
||
updateBulkBtn();
|
||
}
|
||
|
||
async function bulkDeleteRequests(status) {
|
||
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||
const ids = [...box.querySelectorAll("[data-sel-req]:checked")].map((c) => c.dataset.selReq);
|
||
if (!ids.length) return;
|
||
const hint = status === "imported"
|
||
? `${ids.length} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.`
|
||
: `${ids.length} Anfrage(n) entfernen?`;
|
||
if (!confirm(hint)) return;
|
||
for (const id of ids) {
|
||
try { await api("/api/requests/" + id, { method: "DELETE" }); }
|
||
catch (err) { toast(err.message, true); }
|
||
}
|
||
toast(`${ids.length} entfernt`);
|
||
loadRequests(status);
|
||
}
|
||
|
||
$("#imported-bulk-delete").addEventListener("click", () => bulkDeleteRequests("imported"));
|
||
|
||
function selectAllRequests(status) {
|
||
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||
const checks = [...box.querySelectorAll("[data-sel-req]")];
|
||
const allChecked = checks.length && checks.every((c) => c.checked);
|
||
checks.forEach((c) => (c.checked = !allChecked));
|
||
if (checks.length) checks[0].dispatchEvent(new Event("change"));
|
||
}
|
||
$("#missing-select-all").addEventListener("click", () => selectAllRequests("missing"));
|
||
$("#imported-select-all").addEventListener("click", () => selectAllRequests("imported"));
|
||
|
||
$("#missing-filter-type").addEventListener("change", () => loadRequests("missing"));
|
||
$("#missing-filter-library").addEventListener("change", () => loadRequests("missing"));
|
||
$("#missing-search").addEventListener("input", () => renderRequests("missing"));
|
||
$("#imported-search").addEventListener("input", () => renderRequests("imported"));
|
||
|
||
$("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("missing"));
|
||
|
||
// ---- request detail dialog ----
|
||
let detailRequest = null;
|
||
|
||
function openDetail(id, status) {
|
||
const r = currentRequests[status].find((x) => x.id === id);
|
||
if (!r) return;
|
||
detailRequest = r;
|
||
const form = $("#detail-form");
|
||
form.title.value = r.title;
|
||
form.authors.value = r.authors || "";
|
||
form.narrator.value = r.narrator || "";
|
||
form.series.value = r.series || "";
|
||
form.volume.value = r.volume ?? "";
|
||
form.year.value = r.year ?? "";
|
||
form.external_id.value = r.external_id || "";
|
||
$("#detail-library").innerHTML = libraries
|
||
.filter((l) => l.media_type === r.media_type)
|
||
.map((l) => `<option value="${l.id}" ${l.id === r.library_id ? "selected" : ""}>${esc(l.name)}</option>`)
|
||
.join("");
|
||
$("#detail-title").textContent = r.title;
|
||
$("#detail-status").textContent =
|
||
`${r.media_type} · ${r.status === "missing" ? "fehlt noch" : "importiert"}`;
|
||
$("#detail-path").textContent = r.imported_path || "";
|
||
const img = $("#detail-cover");
|
||
img.hidden = !r.cover_url;
|
||
$("#detail-nocover").hidden = !!r.cover_url;
|
||
if (r.cover_url) img.src = r.cover_url;
|
||
$("#detail-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook");
|
||
$("#detail-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 || "",
|
||
}),
|
||
});
|
||
}
|
||
|
||
$("#detail-retag").addEventListener("click", async () => {
|
||
if (!detailRequest) return;
|
||
const btn = $("#detail-retag");
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Tagge…';
|
||
try {
|
||
await saveDetail($("#detail-form"));
|
||
const res = await api(`/api/requests/${detailRequest.id}/retag`, { method: "POST" });
|
||
toast(`${res.files} Datei(en) neu getaggt`);
|
||
$("#detail-dialog").close();
|
||
loadRequests(detailRequest.status);
|
||
} catch (err) { toast(err.message, true); }
|
||
finally {
|
||
btn.disabled = false;
|
||
btn.textContent = "Speichern & neu taggen";
|
||
}
|
||
});
|
||
|
||
$("#detail-form").addEventListener("submit", async (e) => {
|
||
if (e.submitter && e.submitter.value === "cancel") return;
|
||
if (!detailRequest) return;
|
||
try {
|
||
await saveDetail(e.target);
|
||
toast("Anfrage gespeichert");
|
||
loadRequests(detailRequest.status);
|
||
} catch (err) { toast(err.message, true); }
|
||
});
|
||
|
||
// ---- import ----
|
||
let scanItems = [];
|
||
let missingReqs = [];
|
||
let viewIdx = []; // indices into scanItems after filter/sort
|
||
let importPage = 0;
|
||
const IMPORT_PAGE_SIZE = 25;
|
||
|
||
$("#scan-btn").addEventListener("click", async () => {
|
||
const scanBtn = $("#scan-btn");
|
||
scanBtn.disabled = true;
|
||
$("#scan-info").innerHTML = '<span class="spinner"></span> Scanne…';
|
||
try {
|
||
const split = $("#split-dirs").checked;
|
||
const [scan, reqs] = await Promise.all([
|
||
api("/api/import/scan?split_dirs=" + split),
|
||
api("/api/requests?status=missing"),
|
||
]);
|
||
scanItems = scan.items;
|
||
// selection state lives here, not in the DOM, so it survives paging/filtering
|
||
scanItems.forEach((item) => {
|
||
item.request_id = item.suggested_request_id;
|
||
item.checked = !!item.suggested_request_id;
|
||
});
|
||
missingReqs = reqs;
|
||
importPage = 0;
|
||
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
|
||
$("#import-toolbar").hidden = scanItems.length === 0;
|
||
renderImportTable();
|
||
} catch (err) {
|
||
$("#scan-info").textContent = "";
|
||
toast(err.message, true);
|
||
} finally {
|
||
scanBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
function applyImportView() {
|
||
const text = $("#import-filter-text").value.toLowerCase();
|
||
const mode = $("#import-filter-mode").value;
|
||
const sort = $("#import-sort").value;
|
||
viewIdx = scanItems
|
||
.map((_, i) => i)
|
||
.filter((i) => {
|
||
const item = scanItems[i];
|
||
if (text && !(`${item.name} ${item.rel_dir || ""}`.toLowerCase().includes(text))) return false;
|
||
if (mode === "suggested" && !item.suggested_request_id) return false;
|
||
if (mode === "unassigned" && item.request_id) return false;
|
||
return true;
|
||
});
|
||
if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score);
|
||
if (sort === "name") viewIdx.sort((a, b) => scanItems[a].name.localeCompare(scanItems[b].name));
|
||
}
|
||
|
||
function scoreCell(item) {
|
||
if (!item.suggested_request_id) return '<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 opts = missingReqs
|
||
.filter((r) => r.media_type === item.media_type)
|
||
.map(
|
||
(r) =>
|
||
`<option value="${r.id}" ${r.id === item.request_id ? "selected" : ""}>
|
||
${esc(r.title)} — ${esc(r.authors)} (${esc(r.library_name)})</option>`
|
||
)
|
||
.join("");
|
||
return `<tr>
|
||
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
||
<td class="mono">
|
||
${esc(item.name)}${item.is_dir ? " 📁" : ""}
|
||
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
|
||
</td>
|
||
<td>${esc(item.media_type)}</td>
|
||
<td class="row">
|
||
<select data-select="${i}"><option value="">— überspringen —</option>${opts}</select>
|
||
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
|
||
</td>
|
||
${scoreCell(item)}
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
table.hidden = false;
|
||
$("#import-btn").hidden = false;
|
||
|
||
tbody.querySelectorAll("[data-quick]").forEach((b) =>
|
||
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
|
||
);
|
||
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);
|
||
}
|
||
|
||
function updateImportPageInfo(pages) {
|
||
pages = pages ?? Math.max(1, Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE));
|
||
const selected = scanItems.filter((it) => it.checked && it.request_id).length;
|
||
const filtered = viewIdx.length !== scanItems.length ? ` (gefiltert: ${viewIdx.length})` : "";
|
||
$("#import-page-info").textContent =
|
||
`Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge${filtered} · ${selected} ausgewählt`;
|
||
}
|
||
|
||
$("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); });
|
||
$("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); });
|
||
$("#import-filter-text").addEventListener("input", () => { importPage = 0; renderImportTable(); });
|
||
$("#import-filter-mode").addEventListener("change", () => { importPage = 0; renderImportTable(); });
|
||
$("#import-sort").addEventListener("change", () => { importPage = 0; renderImportTable(); });
|
||
|
||
$("#import-select-suggested").addEventListener("click", () => {
|
||
applyImportView();
|
||
viewIdx.forEach((i) => {
|
||
const item = scanItems[i];
|
||
if (item.suggested_request_id) {
|
||
item.request_id = item.request_id || item.suggested_request_id;
|
||
item.checked = true;
|
||
}
|
||
});
|
||
renderImportTable();
|
||
});
|
||
|
||
$("#import-deselect-all").addEventListener("click", () => {
|
||
applyImportView();
|
||
viewIdx.forEach((i) => (scanItems[i].checked = false));
|
||
renderImportTable();
|
||
});
|
||
|
||
// ---- quick request from a scanned file ----
|
||
let quickItemIndex = null;
|
||
let quickEpisode = null;
|
||
|
||
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
|
||
// A bare number without a separator ("100 Stunden") or without a leading zero is
|
||
// left alone: there it is part of the title, not a numbering.
|
||
const EPISODE_PATTERNS = [
|
||
/^\s*(?:folge|teil|band|nr\.?)\s*(\d{1,4})\s*(?:[-–—._:]+\s*|\s+)/i, // "Folge 124: Titel"
|
||
/^\s*[\[(](\d{1,4})[\])]\s*(?:[-–—._:]+\s*|\s+)/, // "[003] Titel"
|
||
/^\s*(0\d{1,3})\s*(?:[-–—._:]+\s*|\s+)/, // "001 Titel"
|
||
/^\s*(\d{1,3})\s*[-–—._:]+\s*/, // "17 - Titel"
|
||
];
|
||
|
||
function splitEpisodeNumber(name) {
|
||
for (const re of EPISODE_PATTERNS) {
|
||
const m = name.match(re);
|
||
if (!m) continue;
|
||
const rest = name.slice(m[0].length).trim();
|
||
if (!rest) break; // the number *is* the name
|
||
return { number: parseInt(m[1], 10), rest };
|
||
}
|
||
return { number: null, rest: name };
|
||
}
|
||
|
||
function cleanFileName(name) {
|
||
return name
|
||
.replace(/[._\-\[\]()]+/g, " ")
|
||
.replace(/\b(mp3|m4b|flac|epub|pdf|cbz|cbr|retail|unabridged|kompl.*)\b/gi, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function openQuickDialog(i) {
|
||
quickItemIndex = i;
|
||
const item = scanItems[i];
|
||
// the episode number wrecks the Audible keyword search, so it is kept out of
|
||
// the query and used as the volume instead
|
||
const { number, rest } = splitEpisodeNumber(item.name);
|
||
quickEpisode = number;
|
||
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
|
||
$("#quick-episode").textContent = number != null ? `Folge ${number} — nicht mitgesucht` : "";
|
||
$("#quick-q").value = cleanFileName(rest);
|
||
$("#quick-library").innerHTML =
|
||
libOptions(item.media_type) || "<option value=''>— keine Library —</option>";
|
||
$("#quick-results").innerHTML = "";
|
||
renderQuickOpenRequests(item);
|
||
$("#quick-dialog").showModal();
|
||
runQuickSearch();
|
||
}
|
||
|
||
// offer linking to similar already-open requests instead of creating a duplicate
|
||
function renderQuickOpenRequests(item) {
|
||
const box = $("#quick-open-requests");
|
||
const tokens = cleanFileName(item.name).toLowerCase().split(" ").filter((t) => t.length > 2);
|
||
const matches = missingReqs
|
||
.filter((r) => r.media_type === item.media_type)
|
||
.map((r) => {
|
||
const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase();
|
||
return { r, hits: tokens.filter((t) => hay.includes(t)).length };
|
||
})
|
||
.filter((m) => m.hits >= 2)
|
||
.sort((a, b) => b.hits - a.hits)
|
||
.slice(0, 3);
|
||
if (!matches.length) { box.innerHTML = ""; return; }
|
||
box.innerHTML =
|
||
'<p class="muted">Passende offene Requests:</p>' +
|
||
matches
|
||
.map(
|
||
({ r }) => `<div class="row open-req">
|
||
<span>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""} — ${esc(r.authors)} (${esc(r.library_name)})</span>
|
||
<button class="secondary" data-link-req="${r.id}">Verbinden</button>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
box.querySelectorAll("[data-link-req]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const i = quickItemIndex;
|
||
scanItems[i].request_id = parseInt(b.dataset.linkReq);
|
||
scanItems[i].checked = true;
|
||
renderImportTable();
|
||
$("#quick-dialog").close();
|
||
toast("Mit bestehendem Request verbunden");
|
||
})
|
||
);
|
||
}
|
||
|
||
async function runQuickSearch() {
|
||
const item = scanItems[quickItemIndex];
|
||
const box = $("#quick-results");
|
||
box.innerHTML = "<p class='muted'>Suche läuft…</p>";
|
||
try {
|
||
const results = await api(
|
||
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}`
|
||
);
|
||
if (!results.length) {
|
||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
|
||
return;
|
||
}
|
||
box.innerHTML = results
|
||
.map(
|
||
(r, j) => `<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 ?? ""}</span>
|
||
<div class="row"><button data-pick="${j}">Anfragen & verbinden</button></div>
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
box.querySelectorAll("[data-pick]").forEach((btn) =>
|
||
btn.addEventListener("click", () => pickQuickResult(results[btn.dataset.pick]))
|
||
);
|
||
} catch (err) {
|
||
box.innerHTML = "";
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
|
||
$("#quick-search-form").addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
runQuickSearch();
|
||
});
|
||
$("#quick-close").addEventListener("click", () => $("#quick-dialog").close());
|
||
|
||
async function pickQuickResult(r) {
|
||
const libId = $("#quick-library").value;
|
||
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
|
||
const i = quickItemIndex;
|
||
try {
|
||
const req = await createRequest({
|
||
library_id: parseInt(libId),
|
||
title: r.title, authors: r.authors, narrator: r.narrator || "",
|
||
external_id: r.external_id, volume: r.volume ?? quickEpisode,
|
||
year: r.year, series: r.series, cover_url: r.cover_url,
|
||
});
|
||
setLastLib(scanItems[i].media_type, libId);
|
||
missingReqs.push(req);
|
||
scanItems[i].request_id = req.id;
|
||
scanItems[i].checked = true;
|
||
renderImportTable();
|
||
refreshMissingBadge();
|
||
$("#quick-dialog").close();
|
||
toast(`„${req.title}" angefragt und verbunden`);
|
||
} catch (err) { toast(err.message, true); }
|
||
}
|
||
|
||
// ---- import execution (batched, with progress) ----
|
||
const IMPORT_BATCH_SIZE = 20;
|
||
|
||
$("#import-btn").addEventListener("click", async () => {
|
||
const items = scanItems
|
||
.filter((item) => item.checked && item.request_id)
|
||
.map((item) => ({
|
||
path: item.path, is_dir: item.is_dir, files: item.files,
|
||
request_id: item.request_id,
|
||
}));
|
||
if (!items.length) { toast("Nichts ausgewählt", true); return; }
|
||
const btn = $("#import-btn");
|
||
btn.disabled = true;
|
||
const allResults = [];
|
||
try {
|
||
for (let done = 0; done < items.length; done += IMPORT_BATCH_SIZE) {
|
||
btn.innerHTML =
|
||
`<span class="spinner"></span> Importiere… ${Math.min(done + IMPORT_BATCH_SIZE, items.length)}/${items.length}`;
|
||
const batch = items.slice(done, done + IMPORT_BATCH_SIZE);
|
||
const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items: batch }) });
|
||
allResults.push(...res.results);
|
||
}
|
||
const failed = allResults.filter((r) => !r.ok);
|
||
$("#import-summary").innerHTML =
|
||
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
|
||
`${allResults.length - failed.length} importiert` +
|
||
(failed.length ? `, ${failed.length} Fehler` : "") +
|
||
`</strong></p>`;
|
||
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
|
||
.map((r) =>
|
||
r.ok
|
||
? `<p class="ok">✓ ${esc(r.path)} → ${esc(r.dest)}</p>`
|
||
: `<p class="error">✗ ${esc(r.path)}: ${esc(r.error)}</p>`
|
||
)
|
||
.join("");
|
||
toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", failed.length > 0);
|
||
refreshMissingBadge();
|
||
$("#scan-btn").click();
|
||
} catch (err) { toast(err.message, true); }
|
||
finally {
|
||
btn.disabled = false;
|
||
btn.textContent = "Ausgewählte importieren";
|
||
}
|
||
});
|
||
|
||
// ---- init ----
|
||
loadLibraries();
|
||
refreshMissingBadge();
|