Add wordarr: request & import manager for ebooks, comics and audiobooks
FastAPI + SQLite backend with vanilla-JS web UI. Requests via Open Library / Audible / AniList metadata search (or manual entry) with per-request target library selection. Manual import flow: scan the download dir, fuzzy-match files against missing requests, review, then move+rename into the library per configurable naming templates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3789ad0a37
commit
508ae5f26e
+291
@@ -0,0 +1,291 @@
|
||||
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); }
|
||||
});
|
||||
|
||||
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 {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
e.target.reset();
|
||||
toast("Anfrage angelegt");
|
||||
} 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 () => {
|
||||
$("#scan-info").textContent = "Scanne…";
|
||||
try {
|
||||
const [scan, reqs] = await Promise.all([
|
||||
api("/api/import/scan"),
|
||||
api("/api/requests?status=missing"),
|
||||
]);
|
||||
scanItems = scan.items;
|
||||
missingReqs = reqs;
|
||||
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
|
||||
renderImportTable();
|
||||
} catch (err) {
|
||||
$("#scan-info").textContent = "";
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
function renderImportTable() {
|
||||
const table = $("#import-table");
|
||||
const tbody = table.querySelector("tbody");
|
||||
if (!scanItems.length) { table.hidden = true; $("#import-btn").hidden = true; return; }
|
||||
tbody.innerHTML = scanItems
|
||||
.map((item, i) => {
|
||||
const opts = missingReqs
|
||||
.filter((r) => r.media_type === item.media_type)
|
||||
.map(
|
||||
(r) =>
|
||||
`<option value="${r.id}" ${r.id === item.suggested_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.suggested_request_id ? "checked" : ""}></td>
|
||||
<td class="mono">${esc(item.name)}${item.is_dir ? " 📁" : ""}</td>
|
||||
<td>${esc(item.media_type)}</td>
|
||||
<td><select data-select="${i}"><option value="">— überspringen —</option>${opts}</select></td>
|
||||
<td>${item.suggested_request_id ? item.score : "—"}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
table.hidden = false;
|
||||
$("#import-btn").hidden = false;
|
||||
}
|
||||
|
||||
$("#import-btn").addEventListener("click", async () => {
|
||||
const items = [];
|
||||
scanItems.forEach((item, i) => {
|
||||
const checked = document.querySelector(`[data-check="${i}"]`).checked;
|
||||
const reqId = document.querySelector(`[data-select="${i}"]`).value;
|
||||
if (checked && reqId) {
|
||||
items.push({ path: item.path, is_dir: item.is_dir, files: item.files, request_id: parseInt(reqId) });
|
||||
}
|
||||
});
|
||||
if (!items.length) { toast("Nichts ausgewählt", true); return; }
|
||||
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("");
|
||||
$("#scan-btn").click();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
});
|
||||
|
||||
// ---- init ----
|
||||
loadLibraries();
|
||||
@@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>wordarr</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>📚 wordarr</h1>
|
||||
<nav>
|
||||
<button data-view="search" class="active">Suche</button>
|
||||
<button data-view="missing">Missing</button>
|
||||
<button data-view="imported">Importiert</button>
|
||||
<button data-view="import">Import</button>
|
||||
<button data-view="settings">Libraries</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section id="view-search">
|
||||
<form id="search-form" class="bar">
|
||||
<select id="search-type">
|
||||
<option value="ebook">Ebook</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
<option value="comic">Comic/Manga</option>
|
||||
</select>
|
||||
<input id="search-q" placeholder="Titel, Autor oder ISBN…" required>
|
||||
<button type="submit">Suchen</button>
|
||||
<button type="button" id="manual-btn" class="secondary">Manuell anlegen</button>
|
||||
</form>
|
||||
<div id="search-results" class="cards"></div>
|
||||
|
||||
<dialog id="manual-dialog">
|
||||
<form id="manual-form" method="dialog">
|
||||
<h3>Manuell anfragen</h3>
|
||||
<label>Typ
|
||||
<select name="media_type">
|
||||
<option value="ebook">Ebook</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
<option value="comic">Comic/Manga</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Titel <input name="title" required></label>
|
||||
<label>Autor(en) <input name="authors"></label>
|
||||
<label>Serie <input name="series"></label>
|
||||
<label>Band <input name="volume" type="number" min="0"></label>
|
||||
<label>Jahr <input name="year" type="number"></label>
|
||||
<label>ISBN/ID <input name="external_id"></label>
|
||||
<label>Library <select name="library_id" id="manual-library"></select></label>
|
||||
<div class="row">
|
||||
<button value="cancel" class="secondary">Abbrechen</button>
|
||||
<button value="ok" id="manual-submit">Anfragen</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</section>
|
||||
|
||||
<section id="view-missing" hidden>
|
||||
<div class="bar">
|
||||
<select id="missing-filter-type">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="ebook">Ebook</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
<option value="comic">Comic/Manga</option>
|
||||
</select>
|
||||
<select id="missing-filter-library"><option value="">Alle Libraries</option></select>
|
||||
</div>
|
||||
<div id="missing-list" class="cards"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-imported" hidden>
|
||||
<div id="imported-list" class="cards"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-import" hidden>
|
||||
<div class="bar">
|
||||
<button id="scan-btn">Download-Ordner scannen</button>
|
||||
<span id="scan-info" class="muted"></span>
|
||||
</div>
|
||||
<table id="import-table" hidden>
|
||||
<thead><tr><th></th><th>Datei/Ordner</th><th>Typ</th><th>Zuordnung</th><th>Score</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="bar">
|
||||
<button id="import-btn" hidden>Ausgewählte importieren</button>
|
||||
</div>
|
||||
<div id="import-results"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-settings" hidden>
|
||||
<h3>Libraries</h3>
|
||||
<table id="lib-table">
|
||||
<thead><tr><th>Name</th><th>Typ</th><th>Pfad</th><th>Ordner-Schema</th><th>Datei-Schema</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<h4>Neue Library</h4>
|
||||
<form id="lib-form" class="bar wrap">
|
||||
<input name="name" placeholder="Name (z.B. Kids)" required>
|
||||
<select name="media_type">
|
||||
<option value="ebook">Ebook</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
<option value="comic">Comic/Manga</option>
|
||||
</select>
|
||||
<input name="root_path" placeholder="/library/ebooks/kids" required class="wide">
|
||||
<input name="folder_template" placeholder="Ordner-Schema (optional)" class="wide">
|
||||
<input name="file_template" placeholder="Datei-Schema (optional)" class="wide">
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
<p class="muted">Platzhalter: {Author} {Authors} {Title} {Year} {Series} {Volume}</p>
|
||||
</section>
|
||||
</main>
|
||||
<div id="toast" hidden></div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #1a1d24; --panel: #23272f; --border: #333945;
|
||||
--text: #e6e8ec; --muted: #8b93a1; --accent: #4c8fdd; --danger: #c0504e;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||
header {
|
||||
display: flex; align-items: center; gap: 2rem;
|
||||
padding: 0.8rem 1.2rem; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
h1 { font-size: 1.2rem; }
|
||||
nav { display: flex; gap: 0.4rem; }
|
||||
nav button { background: none; border: none; color: var(--muted); padding: 0.5rem 0.9rem; cursor: pointer; border-radius: 4px; font-size: 0.95rem; }
|
||||
nav button.active, nav button:hover { color: var(--text); background: var(--bg); }
|
||||
main { padding: 1.2rem; max-width: 1100px; margin: 0 auto; }
|
||||
.bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.bar.wrap { flex-wrap: wrap; }
|
||||
input, select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.7rem; font-size: 0.95rem; }
|
||||
input.wide { min-width: 260px; }
|
||||
#search-q { flex: 1; }
|
||||
button { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 0.5rem 1rem; cursor: pointer; font-size: 0.95rem; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.secondary { background: var(--border); }
|
||||
button.danger { background: var(--danger); padding: 0.3rem 0.7rem; font-size: 0.85rem; }
|
||||
button:disabled { opacity: 0.6; cursor: default; }
|
||||
.cards { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.card { display: flex; gap: 0.8rem; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem; }
|
||||
.card img, .nocover { width: 60px; height: 90px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
|
||||
.nocover { background: var(--bg); display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 1.5rem; }
|
||||
.card-body { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.row { display: flex; gap: 0.5rem; margin-top: 0.4rem; align-items: center; }
|
||||
.muted { color: var(--muted); font-size: 0.88rem; }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--border); font-size: 0.92rem; }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
td select { max-width: 340px; }
|
||||
.volume { width: 90px; }
|
||||
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; }
|
||||
dialog::backdrop { background: rgba(0,0,0,0.6); }
|
||||
dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5rem 0; font-size: 0.9rem; color: var(--muted); }
|
||||
.ok { color: #6fbf73; }
|
||||
.error { color: #e08585; }
|
||||
#toast { position: fixed; bottom: 1.2rem; right: 1.2rem; background: var(--accent); color: #fff; padding: 0.7rem 1.1rem; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
||||
#toast.error { background: var(--danger); }
|
||||
h3, h4 { margin: 0.8rem 0 0.6rem; }
|
||||
Reference in New Issue
Block a user