471 lines
17 KiB
JavaScript
471 lines
17 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 {}
|
|
throw new Error(msg);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ---- 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><button class="danger" data-del-lib="${l.id}">Löschen</button></td>
|
|
</tr>`
|
|
)
|
|
.join("");
|
|
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("");
|
|
}
|
|
|
|
$("#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;
|
|
try {
|
|
await api("/api/libraries", { method: "POST", body: JSON.stringify(data) });
|
|
e.target.reset();
|
|
loadLibraries();
|
|
toast("Library angelegt");
|
|
} 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);
|
|
});
|
|
}
|
|
|
|
function libOptions(mediaType) {
|
|
return libraries
|
|
.filter((l) => l.media_type === mediaType)
|
|
.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`)
|
|
.join("");
|
|
}
|
|
|
|
// ---- search & request ----
|
|
$("#search-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const type = $("#search-type").value;
|
|
const q = $("#search-q").value;
|
|
const box = $("#search-results");
|
|
box.innerHTML = "<p class='muted'>Suche läuft…</p>";
|
|
try {
|
|
const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`);
|
|
if (!results.length) { box.innerHTML = "<p class='muted'>Keine Treffer.</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="">` : '<div class="nocover">?</div>'}
|
|
<div class="card-body">
|
|
<strong>${esc(r.title)}</strong>
|
|
<span>${esc(r.authors)}</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>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
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 api("/api/requests", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
library_id: parseInt(libId),
|
|
title: r.title, authors: r.authors, external_id: r.external_id,
|
|
year: r.year, series: r.series, cover_url: r.cover_url,
|
|
volume: volInput && volInput.value ? parseInt(volInput.value) : null,
|
|
}),
|
|
});
|
|
btn.textContent = "✓ Angefragt";
|
|
btn.disabled = true;
|
|
} catch (err) { toast(err.message, true); }
|
|
})
|
|
);
|
|
} catch (err) {
|
|
box.innerHTML = "";
|
|
toast(err.message, true);
|
|
}
|
|
});
|
|
|
|
// ---- 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,
|
|
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 api("/api/requests", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
library_id: parseInt(data.library_id),
|
|
title: data.title, authors: data.authors, series: data.series,
|
|
external_id: data.external_id,
|
|
year: data.year ? parseInt(data.year) : null,
|
|
volume: data.volume ? parseInt(data.volume) : null,
|
|
}),
|
|
});
|
|
toast("Anfrage angelegt");
|
|
}
|
|
e.target.reset();
|
|
} catch (err) { toast(err.message, true); }
|
|
});
|
|
|
|
// ---- missing / imported lists ----
|
|
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 reqs = await api("/api/requests?" + params);
|
|
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
|
if (!reqs.length) { box.innerHTML = "<p class='muted'>Nichts hier.</p>"; return; }
|
|
box.innerHTML = reqs
|
|
.map(
|
|
(r) => `<div class="card">
|
|
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="">` : '<div class="nocover">?</div>'}
|
|
<div class="card-body">
|
|
<strong>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}</strong>
|
|
<span>${esc(r.authors)}</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 class="row"><button class="danger" data-del-req="${r.id}">Entfernen</button></div>
|
|
</div>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
box.querySelectorAll("[data-del-req]").forEach((b) =>
|
|
b.addEventListener("click", async () => {
|
|
if (!confirm("Anfrage entfernen?")) return;
|
|
await api("/api/requests/" + b.dataset.delReq, { method: "DELETE" });
|
|
loadRequests(status);
|
|
})
|
|
);
|
|
}
|
|
$("#missing-filter-type").addEventListener("change", () => loadRequests("missing"));
|
|
$("#missing-filter-library").addEventListener("change", () => loadRequests("missing"));
|
|
|
|
// ---- import ----
|
|
let scanItems = [];
|
|
let missingReqs = [];
|
|
|
|
$("#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
|
|
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}`;
|
|
renderImportTable();
|
|
} catch (err) {
|
|
$("#scan-info").textContent = "";
|
|
toast(err.message, true);
|
|
} finally {
|
|
scanBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
let importPage = 0;
|
|
const IMPORT_PAGE_SIZE = 25;
|
|
|
|
function renderImportTable() {
|
|
const table = $("#import-table");
|
|
const tbody = table.querySelector("tbody");
|
|
if (!scanItems.length) {
|
|
table.hidden = true;
|
|
$("#import-btn").hidden = true;
|
|
$("#import-pager").hidden = true;
|
|
return;
|
|
}
|
|
const pages = Math.ceil(scanItems.length / IMPORT_PAGE_SIZE);
|
|
importPage = Math.min(importPage, pages - 1);
|
|
const start = importPage * IMPORT_PAGE_SIZE;
|
|
const pageItems = scanItems.slice(start, start + IMPORT_PAGE_SIZE);
|
|
|
|
tbody.innerHTML = pageItems
|
|
.map((item, k) => {
|
|
const i = start + k;
|
|
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 ? " 📁" : ""}</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>
|
|
<td>${item.suggested_request_id ? item.score : "—"}</td>
|
|
</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))
|
|
);
|
|
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;
|
|
})
|
|
);
|
|
|
|
const selected = scanItems.filter((it) => it.checked && it.request_id).length;
|
|
$("#import-pager").hidden = false;
|
|
$("#import-page-info").textContent =
|
|
`Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge · ${selected} ausgewählt`;
|
|
$("#import-prev").disabled = importPage === 0;
|
|
$("#import-next").disabled = importPage >= pages - 1;
|
|
}
|
|
|
|
$("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); });
|
|
$("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); });
|
|
|
|
// ---- quick request from a scanned file ----
|
|
let quickItemIndex = null;
|
|
|
|
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];
|
|
$("#quick-file").textContent = item.name;
|
|
$("#quick-q").value = cleanFileName(item.name);
|
|
$("#quick-library").innerHTML =
|
|
libOptions(item.media_type) || "<option value=''>— keine Library —</option>";
|
|
$("#quick-results").innerHTML = "";
|
|
$("#quick-dialog").showModal();
|
|
runQuickSearch();
|
|
}
|
|
|
|
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'>Keine Treffer — Suchbegriff anpassen.</p>"; return; }
|
|
box.innerHTML = results
|
|
.map(
|
|
(r, j) => `<div class="card">
|
|
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="">` : '<div class="nocover">?</div>'}
|
|
<div class="card-body">
|
|
<strong>${esc(r.title)}</strong>
|
|
<span>${esc(r.authors)}</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 api("/api/requests", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
library_id: parseInt(libId),
|
|
title: r.title, authors: r.authors, external_id: r.external_id,
|
|
year: r.year, series: r.series, cover_url: r.cover_url,
|
|
}),
|
|
});
|
|
missingReqs.push(req);
|
|
scanItems[i].request_id = req.id;
|
|
scanItems[i].checked = true;
|
|
renderImportTable();
|
|
$("#quick-dialog").close();
|
|
toast(`„${req.title}" angefragt und verbunden`);
|
|
} catch (err) { toast(err.message, true); }
|
|
}
|
|
|
|
$("#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;
|
|
btn.innerHTML = `<span class="spinner"></span> Importiere ${items.length} Einträge…`;
|
|
try {
|
|
const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items }) });
|
|
$("#import-results").innerHTML = res.results
|
|
.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("");
|
|
const failed = res.results.filter((r) => !r.ok).length;
|
|
toast(failed ? `Import fertig, ${failed} Fehler` : "Import fertig", failed > 0);
|
|
$("#scan-btn").click();
|
|
} catch (err) { toast(err.message, true); }
|
|
finally {
|
|
btn.disabled = false;
|
|
btn.textContent = "Ausgewählte importieren";
|
|
}
|
|
});
|
|
|
|
// ---- init ----
|
|
loadLibraries();
|