Compare commits

Author SHA1 Message Date
Steppenstreuner 1487512183 feat(ui): watch a series from its dialog and review new parts 2026-10-25 20:17:28 +01:00
Steppenstreuner 55d7c8bdda feat(series): watch series and suggest new parts once a day 2026-10-25 20:17:13 +01:00
Steppenstreuner 175dface50 feat(ui): add a system page with health checks, version and logs 2026-10-25 20:09:06 +01:00
Steppenstreuner 4441c5a24d feat(system): report health problems and keep recent log entries 2026-10-25 20:08:52 +01:00
Steppenstreuner b85bdcf5aa fix(ui): center dialogs again after the global margin reset 2026-10-25 20:04:36 +01:00
Steppenstreuner d0b12d5d09 feat(ui): scan a library and link its files to open requests 2026-10-25 19:55:22 +01:00
Steppenstreuner 46c3c91a8d feat(import): adopt files that are already in a library 2026-10-25 19:55:05 +01:00
Steppenstreuner 46b88e2fd5 feat(ui): sort request lists and move selected requests to another library 2026-10-25 19:40:28 +01:00
Steppenstreuner 07c988c443 feat(requests): add bulk update, bulk delete and a count endpoint 2026-10-25 19:40:10 +01:00
Steppenstreuner f7f89a3cc7 feat(connect): scan audiobookshelf and call a webhook after changes 2026-10-25 19:31:02 +01:00
Steppenstreuner 40a829c80c feat(ui): show the history in an activity view 2026-10-25 19:22:30 +01:00
Steppenstreuner 0c504790df feat(history): record what happens to a request 2026-10-25 19:21:56 +01:00
Steppenstreuner 53927f7e11 refactor(api): use pydantic v2 model_config 2026-10-25 19:17:15 +01:00
Steppenstreuner adc708ae29 feat(ui): keep the current view in the url hash 2026-10-25 19:16:48 +01:00
Steppenstreuner 5f4b3cb564 fix(mover): rename the folder on relocate so audiobookshelf keeps the item and its cover
Build image / image (push) Successful in 52s
2026-09-25 16:40:20 +02:00
Steppenstreuner b51d5754e0 fix(metadata): keep hardcover books whose editions carry no language 2026-09-25 16:11:35 +02:00
Steppenstreuner 91ad7ff98b fix(metadata): find audiobook series through hardcover when audible leaves them untagged
Build image / image (push) Successful in 51s
2026-09-25 16:02:43 +02:00
Steppenstreuner c8d4d239ec fix(ui/import): read author, series and volume out of the download folder name
Build image / image (push) Successful in 1m38s
2026-09-24 13:19:13 +02:00
Steppenstreuner 13e2a0693e fix(metadata): pick the right book row per hardcover series position
Build image / image (push) Successful in 1m49s
2026-09-15 09:10:43 +02:00
Steppenstreuner dc83071a0a fix(ci): drop the socket mount the runnter already provides
Build image / image (push) Successful in 49s
2026-09-01 18:18:08 +02:00
Steppenstreuner e22eded2bf add(ci): pull and recreate the wordarr container after a successful push
Build image / image (push) Failing after 0s
2026-09-01 18:12:22 +02:00
24 changed files with 1886 additions and 94 deletions

No files matched your search

+28 -4
View File
@@ -8,6 +8,11 @@ on:
jobs: jobs:
image: image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: gitea/runner-images:ubuntu-latest
# den Docker-Socket haengt der Runner selbst ein, ein zweiter Mount
# waere ein "Duplicate mount point"
options: -v /home/jazz/stacks:/deploy-target
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -20,18 +25,37 @@ jobs:
- name: Image bauen und pushen - name: Image bauen und pushen
env: env:
# GITEA_TOKEN reicht, wenn der Runner Packages schreiben darf;
# sonst ein PAT mit package:write als Secret REGISTRY_TOKEN hinterlegen.
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER }}
ACTOR: ${{ gitea.actor }}
run: | run: |
command -v docker >/dev/null || { apt-get update -qq && apt-get install -y -qq docker.io; } command -v docker >/dev/null || { apt-get update -qq && apt-get install -y -qq docker.io; }
sha=$(git rev-parse --short HEAD) sha=$(git rev-parse --short HEAD)
image=gitea.steppencloud.de/admin/wordarr image=gitea.steppencloud.de/admin/wordarr
token=${REGISTRY_TOKEN:-$GITEA_TOKEN} token=${REGISTRY_TOKEN:-$GITEA_TOKEN}
echo "$token" | docker login gitea.steppencloud.de -u "${{ gitea.actor }}" --password-stdin echo "$token" | docker login gitea.steppencloud.de -u "${REGISTRY_USER:-$ACTOR}" --password-stdin
# der sha landet als Build-Stempel neben der Version im UI
docker build --build-arg WORDARR_BUILD="$sha" -t "$image:$sha" -t "$image:latest" . docker build --build-arg WORDARR_BUILD="$sha" -t "$image:$sha" -t "$image:latest" .
docker push "$image:$sha" docker push "$image:$sha"
docker push "$image:latest" docker push "$image:latest"
echo "Gepusht: $image:latest ($sha)" echo "Gepusht: $image:latest ($sha)"
- name: wordarr im arr-Stack neu ziehen
run: |
mountpoint -q /deploy-target || {
echo "::error::/deploy-target ist nicht gemountet - valid_volumes im act_runner pruefen"
exit 1
}
if docker compose version >/dev/null 2>&1; then
compose="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
compose="docker-compose"
else
apt-get update -qq && apt-get install -y -qq docker-compose-v2
compose="docker compose"
fi
cd /deploy-target/arr
$compose pull wordarr # ohne pull bleibt :latest das alte Image
$compose up -d wordarr
echo "Laeuft:"
docker inspect -f '{{.Config.Image}} seit {{.State.StartedAt}}' wordarr
+6 -1
View File
@@ -25,6 +25,9 @@ the same mount, otherwise moving turns into copy + delete.
| `WORDARR_EXTRA_EBOOK_EXTENSIONS` | – | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` | | `WORDARR_EXTRA_EBOOK_EXTENSIONS` | – | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` |
| `WORDARR_HARDCOVER_TOKEN` | – | Hardcover API token, the best ebook source; without it Hardcover is skipped | | `WORDARR_HARDCOVER_TOKEN` | – | Hardcover API token, the best ebook source; without it Hardcover is skipped |
| `WORDARR_GOOGLE_BOOKS_KEY` | – | optional, only needed when the keyless Google Books quota runs dry | | `WORDARR_GOOGLE_BOOKS_KEY` | – | optional, only needed when the keyless Google Books quota runs dry |
| `WORDARR_ABS_URL` | – | Audiobookshelf base URL, e.g. `http://audiobookshelf`; after an import, relocate or retag it scans the libraries whose folders hold the files, so both containers must mount them under the same path |
| `WORDARR_ABS_TOKEN` | – | API token of an Audiobookshelf admin (only admins may start a scan) |
| `WORDARR_WEBHOOK_URL` | – | gets every history row as JSON (`event`, `request_id`, `title`, `media_type`, `source`, `dest`, `message`) |
| `WORDARR_BUILD` | – | shown next to the version, e.g. the git sha; also a build arg of the image | | `WORDARR_BUILD` | – | shown next to the version, e.g. the git sha; also a build arg of the image |
The header shows the running version and, behind it, the build stamp (the newest The header shows the running version and, behind it, the build stamp (the newest
@@ -42,7 +45,9 @@ between them and remembers the choice. Without one, the browser language decides
builds and pushes `gitea.steppencloud.de/admin/wordarr` as `:latest` and builds and pushes `gitea.steppencloud.de/admin/wordarr` as `:latest` and
`:<short sha>`, passing that sha as `WORDARR_BUILD`. It logs in with `:<short sha>`, passing that sha as `WORDARR_BUILD`. It logs in with
`REGISTRY_TOKEN` if the secret exists, otherwise with the runner's own `REGISTRY_TOKEN` if the secret exists, otherwise with the runner's own
`GITEA_TOKEN`. Rolling the new image out stays a manual step (dockhand). `GITEA_TOKEN`. The last step pulls the fresh image on the host and recreates
just the wordarr container of the arr stack, so the version in the header is the
commit that was pushed.
## Usage ## Usage
+498 -36
View File
@@ -39,12 +39,16 @@ async function createRequest(body) {
} }
} }
let toastTimer;
function toast(msg, isError = false) { function toast(msg, isError = false) {
const t = $("#toast"); const t = $("#toast");
t.textContent = msg; t.textContent = msg;
t.className = isError ? "error" : ""; t.className = isError ? "error" : "";
t.hidden = false; t.hidden = false;
setTimeout(() => (t.hidden = true), 4000); // 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(...)}") // quotes included: the result also lands inside attributes (value="${esc(...)}")
@@ -80,29 +84,42 @@ function libOptions(mediaType) {
// ---- missing badge ---- // ---- missing badge ----
async function refreshMissingBadge() { async function refreshMissingBadge() {
try { try {
const reqs = await api("/api/requests?status=missing"); const { count } = await api("/api/requests/count?status=missing");
const badge = $("#missing-badge"); const badge = $("#missing-badge");
badge.textContent = reqs.length; badge.textContent = count;
badge.hidden = reqs.length === 0; badge.hidden = count === 0;
} catch {} } catch {}
} }
// ---- navigation ---- // ---- navigation ----
// the view lives in the hash so reload, back button and links keep it
const VIEWS = ["search", "missing", "imported", "import", "series", "activity", "settings", "system"];
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 === "system") loadSystem();
if (name === "series") loadWatches();
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
}
$$("nav button[data-view]").forEach((btn) => $$("nav button[data-view]").forEach((btn) =>
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
$$("nav button[data-view]").forEach((b) => b.classList.remove("active")); // same hash fires no hashchange, but a click should still reload the list
btn.classList.add("active"); if (location.hash === "#" + btn.dataset.view) showView();
$$("main > section").forEach((s) => (s.hidden = true)); else location.hash = btn.dataset.view;
$("#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();
}) })
); );
window.addEventListener("hashchange", showView);
// ---- libraries ---- // ---- libraries ----
async function loadLibraries() { async function loadLibraries() {
libraries = await api("/api/libraries"); libraries = await api("/api/libraries");
refreshHealth();
const tbody = $("#lib-table tbody"); const tbody = $("#lib-table tbody");
tbody.innerHTML = libraries tbody.innerHTML = libraries
.map( .map(
@@ -112,6 +129,7 @@ async function loadLibraries() {
<td>${l.language ? esc(languageName(l.language)) : "—"}</td> <td>${l.language ? esc(languageName(l.language)) : "—"}</td>
<td class="row"> <td class="row">
<button class="secondary" data-edit-lib="${l.id}">${t("common.edit")}</button> <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> <button class="danger" data-del-lib="${l.id}">${t("common.delete")}</button>
</td> </td>
</tr>` </tr>`
@@ -120,6 +138,9 @@ async function loadLibraries() {
tbody.querySelectorAll("[data-edit-lib]").forEach((b) => tbody.querySelectorAll("[data-edit-lib]").forEach((b) =>
b.addEventListener("click", () => startLibraryEdit(parseInt(b.dataset.editLib))) 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) => tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
b.addEventListener("click", async () => { b.addEventListener("click", async () => {
if (!confirm(t("lib.confirmDelete"))) return; if (!confirm(t("lib.confirmDelete"))) return;
@@ -300,6 +321,8 @@ function renderSearchResults() {
// ---- request a whole series ---- // ---- request a whole series ----
let seriesEpisodes = []; let seriesEpisodes = [];
let seriesType = "audiobook"; let seriesType = "audiobook";
let seriesResult = null;
let seriesLanguage = "";
function renderSeriesList() { function renderSeriesList() {
const from = parseInt($("#series-from").value); const from = parseInt($("#series-from").value);
@@ -346,6 +369,8 @@ function updateSeriesCount() {
async function openSeriesDialog(result, type = "audiobook", language = "") { async function openSeriesDialog(result, type = "audiobook", language = "") {
const dlg = $("#series-dialog"); const dlg = $("#series-dialog");
seriesType = type; seriesType = type;
seriesResult = result;
seriesLanguage = language;
const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes"); const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes");
$("#series-title").textContent = result.series || t("series.fallbackTitle"); $("#series-title").textContent = result.series || t("series.fallbackTitle");
$("#series-info").innerHTML = $("#series-info").innerHTML =
@@ -360,7 +385,8 @@ async function openSeriesDialog(result, type = "audiobook", language = "") {
seriesEpisodes = await api( seriesEpisodes = await api(
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` + `/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
`&title=${encodeURIComponent(result.series || result.title)}` + `&title=${encodeURIComponent(result.series || result.title)}` +
`&media_type=${type}&language=${encodeURIComponent(language)}` `&media_type=${type}&language=${encodeURIComponent(language)}` +
`&source=${encodeURIComponent(result.source || "")}`
); );
} catch (err) { } catch (err) {
$("#series-info").textContent = err.message; $("#series-info").textContent = err.message;
@@ -390,6 +416,31 @@ $("#series-none").addEventListener("click", () => {
}); });
$("#series-cancel").addEventListener("click", () => $("#series-dialog").close()); $("#series-cancel").addEventListener("click", () => $("#series-dialog").close());
$("#series-watch").addEventListener("click", async () => {
const libId = $("#series-library").value;
if (!libId) {
toast(t("series.needLibrary", { type: seriesType === "ebook" ? "Ebook" : "Audiobook" }), true);
return;
}
const btn = $("#series-watch");
btn.disabled = true;
try {
await api("/api/series", {
method: "POST",
body: JSON.stringify({
media_type: seriesType, library_id: parseInt(libId),
series_id: seriesResult.series_id, source: seriesResult.source || "",
title: seriesResult.series || seriesResult.title, language: seriesLanguage,
}),
});
toast(t("watch.added"));
} catch (err) {
toast(err.status === 409 ? t("watch.already") : err.message, true);
} finally {
btn.disabled = false;
}
});
$("#series-submit").addEventListener("click", async () => { $("#series-submit").addEventListener("click", async () => {
const libId = $("#series-library").value; const libId = $("#series-library").value;
if (!libId) { if (!libId) {
@@ -504,6 +555,22 @@ function requestMatchesFilter(r, text) {
return hay.includes(text.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) { async function loadRequests(status) {
const params = new URLSearchParams({ status }); const params = new URLSearchParams({ status });
if (status === "missing") { if (status === "missing") {
@@ -512,7 +579,15 @@ async function loadRequests(status) {
if (t) params.set("media_type", t); if (t) params.set("media_type", t);
if (l) params.set("library_id", l); if (l) params.set("library_id", l);
} }
currentRequests[status] = await api("/api/requests?" + params); 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); renderRequests(status);
if (status === "missing") refreshMissingBadge(); if (status === "missing") refreshMissingBadge();
} }
@@ -520,16 +595,20 @@ async function loadRequests(status) {
function renderRequests(status) { function renderRequests(status) {
const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value; const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value;
const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text)); 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 box = status === "missing" ? $("#missing-list") : $("#imported-list");
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete"); const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
const moveSel = status === "missing" ? $("#missing-move-library") : null;
if (!reqs.length) { if (!reqs.length) {
box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`; box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`;
bulkBtn.hidden = true; bulkBtn.hidden = true;
if (moveSel) moveSel.hidden = true;
return; return;
} }
box.innerHTML = reqs box.innerHTML = reqs
.map( .map(
(r) => `<div class="card clickable" data-detail="${r.id}"> (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>'} ${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body"> <div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong> <strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong>
@@ -548,10 +627,21 @@ function renderRequests(status) {
openDetail(parseInt(card.dataset.detail), status); 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 updateBulkBtn = () => {
const n = box.querySelectorAll("[data-sel-req]:checked").length; const n = box.querySelectorAll("[data-sel-req]:checked").length;
bulkBtn.hidden = n === 0; bulkBtn.hidden = n === 0;
bulkBtn.textContent = t("common.removeSelectedN", { n }); 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) => box.querySelectorAll("[data-sel-req]").forEach((c) =>
c.addEventListener("change", updateBulkBtn) c.addEventListener("change", updateBulkBtn)
@@ -566,14 +656,29 @@ async function bulkDeleteRequests(status) {
const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests", const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests",
{ n: ids.length }); { n: ids.length });
if (!confirm(hint)) return; if (!confirm(hint)) return;
for (const id of ids) { try {
try { await api("/api/requests/" + id, { method: "DELETE" }); } const res = await api("/api/requests/bulk", {
catch (err) { toast(err.message, true); } method: "DELETE", body: JSON.stringify({ ids: ids.map(Number) }),
} });
toast(t("list.deletedN", { n: ids.length })); toast(t("list.deletedN", { n: res.deleted }));
} catch (err) { toast(err.message, true); }
loadRequests(status); 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")); $("#imported-bulk-delete").addEventListener("click", () => bulkDeleteRequests("imported"));
function selectAllRequests(status) { function selectAllRequests(status) {
@@ -593,6 +698,170 @@ $("#imported-search").addEventListener("input", () => renderRequests("imported")
$("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("missing")); $("#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("");
}
// ---- watched series ----
let watches = [];
async function loadWatches(path = "/api/series", opts = {}) {
try {
watches = await api(path, opts);
} catch (err) {
toast(err.message, true);
return;
}
renderWatches();
}
function renderWatches() {
const n = watches.reduce((sum, w) => sum + w.new.length, 0);
$("#watch-badge").textContent = n;
$("#watch-badge").hidden = n === 0;
const box = $("#watch-list");
if (!watches.length) {
box.innerHTML = `<p class="muted">${t("watch.empty")}</p>`;
return;
}
const locale = lang === "de" ? "de-DE" : "en-GB";
box.innerHTML = watches
.map((w) => {
const checked = w.checked_at ? new Date(w.checked_at).toLocaleString(locale) : "—";
const parts = w.new
.map((p) => `<tr>
<td>${p.volume ?? ""}</td>
<td>${esc(p.title)}${p.year ? ` <span class="muted">(${p.year})</span>` : ""}</td>
<td class="muted">${esc(p.authors)}</td>
<td class="row">
<button data-part="${p.id}" data-action="request">${t("common.request")}</button>
<button class="secondary" data-part="${p.id}" data-action="ignore">${t("watch.ignore")}</button>
</td>
</tr>`)
.join("");
return `<div class="watch">
<div class="row">
<strong>${esc(w.title)}</strong>
<span class="muted">${esc(w.library_name)} · ${esc(w.media_type)} · ${t("watch.checked", { at: checked })}</span>
<button class="danger" data-unwatch="${w.id}">${t("watch.remove")}</button>
</div>
${w.error ? `<p class="muted error-text">${esc(t("watch.error", { error: w.error }))}</p>` : ""}
${parts ? `<table><tbody>${parts}</tbody></table>` : `<p class="muted">${t("watch.nothingNew")}</p>`}
</div>`;
})
.join("");
}
$("#watch-list").addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
try {
if (btn.dataset.part) {
btn.disabled = true;
await api("/api/series/parts", {
method: "POST",
body: JSON.stringify({ ids: [parseInt(btn.dataset.part)], action: btn.dataset.action }),
});
if (btn.dataset.action === "request") refreshMissingBadge();
} else if (btn.dataset.unwatch) {
if (!confirm(t("watch.confirmRemove"))) return;
await api(`/api/series/${btn.dataset.unwatch}`, { method: "DELETE" });
} else return;
} catch (err) {
toast(err.message, true);
}
loadWatches();
});
$("#watch-check").addEventListener("click", async () => {
const btn = $("#watch-check");
btn.disabled = true;
await loadWatches("/api/series/check", { method: "POST" });
btn.disabled = false;
});
// ---- system ----
let healthIssues = [];
let logRows = [];
// also called after library changes, so a fixed root path clears the badge
async function refreshHealth() {
try {
healthIssues = await api("/api/system/health");
} catch {
return;
}
const badge = $("#health-badge");
badge.textContent = healthIssues.length;
badge.hidden = !healthIssues.length;
badge.classList.toggle("error", healthIssues.some((i) => i.level === "error"));
badge.classList.toggle("warn", !healthIssues.some((i) => i.level === "error"));
renderHealth();
}
async function loadSystem() {
refreshHealth();
try {
logRows = await api("/api/system/logs");
} catch (err) {
toast(err.message, true);
}
renderLogs();
}
function renderHealth() {
$("#health-list").innerHTML = healthIssues.length
? healthIssues.map((i) => `<li class="${i.level}">${esc(t("health." + i.code, i))}</li>`).join("")
: `<li class="ok">${t("system.healthy")}</li>`;
}
function renderLogs() {
const tbody = $("#log-table tbody");
if (!logRows.length) {
tbody.innerHTML = `<tr><td colspan="4" class="muted">${t("common.nothingHere")}</td></tr>`;
return;
}
const locale = lang === "de" ? "de-DE" : "en-GB";
const LEVEL_STATE = { WARNING: "warn", ERROR: "failed", CRITICAL: "failed" };
tbody.innerHTML = logRows
.map((r) => `<tr>
<td class="state">${esc(new Date(r.at).toLocaleString(locale))}</td>
<td class="state ${LEVEL_STATE[r.level] || ""}">${esc(r.level)}</td>
<td class="muted mono">${esc(r.logger)}</td>
<td class="mono">${esc(r.message)}</td>
</tr>`)
.join("");
}
// ---- request detail dialog ---- // ---- request detail dialog ----
let detailRequest = null; let detailRequest = null;
@@ -826,6 +1095,8 @@ function renderImportTable() {
.filter((r) => r.media_type === item.media_type && r.imported_path) .filter((r) => r.media_type === item.media_type && r.imported_path)
.map((r) => option(r, " ↩︎")) .map((r) => option(r, " ↩︎"))
.join(""); .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" : ""}"> return `<tr class="${item.importState ? "row-" + item.importState : item.conflict ? "row-conflict" : ""}">
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td> <td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
<td class="mono"> <td class="mono">
@@ -843,6 +1114,11 @@ function renderImportTable() {
${appendOpts ? `<optgroup label="${esc(t("import.appendGroup"))}">${appendOpts}</optgroup>` : ""} ${appendOpts ? `<optgroup label="${esc(t("import.appendGroup"))}">${appendOpts}</optgroup>` : ""}
</select> </select>
<button class="secondary" data-quick="${i}" title="${esc(t("import.quickTitle"))}">🔍</button> <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 ${item.parts || item.maybe_separate
? `<button class="secondary" data-split="${i}" title="${esc(t(item.parts ? "import.unmerge" : "import.splitDir"))}">✂️</button>` ? `<button class="secondary" data-split="${i}" title="${esc(t(item.parts ? "import.unmerge" : "import.splitDir"))}">✂️</button>`
: ""} : ""}
@@ -1010,8 +1286,8 @@ $("#import-deselect-all").addEventListener("click", () => {
let namesTargets = []; let namesTargets = [];
function nameToRequest(item) { function nameToRequest(item) {
const { number, rest } = splitEpisodeNumber(item.name); const { title, volume } = parseDownloadName(item.name);
return { title: cleanFileName(rest) || item.name, volume: number }; return { title, volume };
} }
$("#import-from-names").addEventListener("click", () => { $("#import-from-names").addEventListener("click", () => {
@@ -1023,8 +1299,10 @@ $("#import-from-names").addEventListener("click", () => {
const type = namesTargets[0].media_type; const type = namesTargets[0].media_type;
namesTargets = namesTargets.filter((it) => it.media_type === type); namesTargets = namesTargets.filter((it) => it.media_type === type);
$("#names-intro").textContent = t("names.intro", { n: namesTargets.length, type }); $("#names-intro").textContent = t("names.intro", { n: namesTargets.length, type });
$("#names-series").value = lastSeries; // the folder names usually carry both, e.g. "Autor - Serie 03 - Titel"
$("#names-authors").value = ""; const first = parseDownloadName(namesTargets[0].name);
$("#names-series").value = first.series || lastSeries;
$("#names-authors").value = first.authors;
$("#names-library").innerHTML = libOptions(type) || noLibraryOption(); $("#names-library").innerHTML = libOptions(type) || noLibraryOption();
renderNamesPreview(); renderNamesPreview();
$("#names-dialog").showModal(); $("#names-dialog").showModal();
@@ -1176,6 +1454,7 @@ function splitItem(i) {
// ---- quick request from a scanned file ---- // ---- quick request from a scanned file ----
let quickItemIndex = null; let quickItemIndex = null;
let quickEpisode = 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 quickPicked = null; // metadata hit the manual fields were filled from
let quickAutoSearch = false; // the search fired on open, not by the user let quickAutoSearch = false; // the search fired on open, not by the user
let lastSeries = ""; let lastSeries = "";
@@ -1200,29 +1479,115 @@ function splitEpisodeNumber(name) {
return { number: null, rest: name }; return { number: null, rest: name };
} }
function cleanFileName(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 return name
.replace(/[._\-\[\]()]+/g, " ") .replace(LANG_TAG_RE, " ")
.replace(/\b(mp3|m4b|flac|epub|pdf|cbz|cbr|retail|unabridged|kompl.*)\b/gi, " ") .replace(RELEASE_TAG_RE, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .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) { function openQuickDialog(i) {
quickItemIndex = i; quickItemIndex = i;
const item = scanItems[i]; const item = scanItems[i];
// the number wrecks the Audible search, so it becomes the volume instead // the number wrecks the Audible search, so it becomes the volume instead
const { number, rest } = splitEpisodeNumber(item.name); const parsed = parseDownloadName(item.name);
quickEpisode = number; quickEpisode = parsed.volume;
quickSeries = parsed.series;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name; $("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
$("#quick-episode").textContent = number != null ? t("quick.episodeNote", { n: number }) : ""; $("#quick-episode").textContent =
$("#quick-q").value = cleanFileName(rest); parsed.volume != null ? t("quick.episodeNote", { n: parsed.volume }) : "";
$("#quick-q").value = parsed.query;
quickPicked = null; quickPicked = null;
$("#quick-req-filter").value = ""; $("#quick-req-filter").value = "";
$("#quick-m-title").value = cleanFileName(rest); $("#quick-m-title").value = parsed.title;
$("#quick-m-authors").value = ""; $("#quick-m-authors").value = parsed.authors;
$("#quick-m-volume").value = number ?? ""; $("#quick-m-volume").value = parsed.volume ?? "";
$("#quick-m-series").value = lastSeries; $("#quick-m-series").value = parsed.series || lastSeries;
$("#quick-based-on").hidden = true; $("#quick-based-on").hidden = true;
$("#quick-manual-summary").textContent = t("quick.manualSummary"); $("#quick-manual-summary").textContent = t("quick.manualSummary");
$("#quick-manual").open = false; $("#quick-manual").open = false;
@@ -1370,7 +1735,7 @@ function pickQuickResult(r) {
return requestAndConnect({ return requestAndConnect({
title: r.title, authors: r.authors, narrator: r.narrator || "", title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id, volume: r.volume ?? quickEpisode, external_id: r.external_id, volume: r.volume ?? quickEpisode,
year: r.year, series: r.series, cover_url: r.cover_url, year: r.year, series: r.series || quickSeries, cover_url: r.cover_url,
}); });
} }
@@ -1380,7 +1745,7 @@ function editBeforeRequest(r) {
quickPicked = r; quickPicked = r;
$("#quick-m-title").value = r.title || ""; $("#quick-m-title").value = r.title || "";
$("#quick-m-authors").value = r.authors || ""; $("#quick-m-authors").value = r.authors || "";
$("#quick-m-series").value = r.series || ""; $("#quick-m-series").value = r.series || quickSeries;
$("#quick-m-volume").value = r.volume ?? quickEpisode ?? ""; $("#quick-m-volume").value = r.volume ?? quickEpisode ?? "";
$("#quick-based-on").textContent = t("quick.basedOn", { title: r.title }); $("#quick-based-on").textContent = t("quick.basedOn", { title: r.title });
$("#quick-based-on").hidden = false; $("#quick-based-on").hidden = false;
@@ -1423,6 +1788,25 @@ function groupByRequest(chosen) {
return [...byRequest.values()]; 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) { function buildImportGroups(groups) {
return groups.map((group) => ({ return groups.map((group) => ({
parts: group, parts: group,
@@ -1432,6 +1816,7 @@ function buildImportGroups(groups) {
files: group.flatMap((it) => it.files), files: group.flatMap((it) => it.files),
request_id: group[0].request_id, request_id: group[0].request_id,
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
...(seriesOverride(group[0]) || {}),
}, },
})); }));
} }
@@ -1600,6 +1985,75 @@ function setImportProgress(done, total) {
`${done}/${total} · ${Math.round((done / total) * 100)} %`; `${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 ---- // ---- language ----
function updateLangButton() { function updateLangButton() {
$("#lang-toggle").textContent = lang === "de" ? "EN" : "DE"; $("#lang-toggle").textContent = lang === "de" ? "EN" : "DE";
@@ -1613,10 +2067,15 @@ function onLangChange() {
renderSearchResults(); renderSearchResults();
renderRequests("missing"); renderRequests("missing");
renderRequests("imported"); renderRequests("imported");
renderHistory();
renderHealth();
renderLogs();
renderWatches();
if (libraries.length) loadLibraries(); if (libraries.length) loadLibraries();
const editId = $("#lib-form").dataset.editId; const editId = $("#lib-form").dataset.editId;
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit(); editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
if (scanItems.length) renderImportTable(); if (scanItems.length) renderImportTable();
renderAdopt();
} }
// ---- version ---- // ---- version ----
@@ -1627,6 +2086,7 @@ async function showVersion() {
// the version alone rarely moves, so the stamp is what tells deploys apart // 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").textContent = `v${v.version} · ${v.build || v.built_at}`;
$("#app-version").title = `${v.built_at} UTC${v.build ? " · " + v.build : ""}`; $("#app-version").title = `${v.built_at} UTC${v.build ? " · " + v.build : ""}`;
$("#system-version").textContent = `v${v.version}${v.build ? " · " + v.build : ""} · ${v.built_at} UTC`;
} catch {} } catch {}
} }
@@ -1636,4 +2096,6 @@ updateLangButton();
endLibraryEdit(); endLibraryEdit();
loadLibraries(); loadLibraries();
refreshMissingBadge(); refreshMissingBadge();
loadWatches();
showVersion(); showVersion();
showView();
+108
View File
@@ -33,6 +33,7 @@ const STRINGS = {
"common.status": "Status", "common.status": "Status",
"common.noLibrary": "— keine Library —", "common.noLibrary": "— keine Library —",
"common.nothingHere": "Nichts hier.", "common.nothingHere": "Nichts hier.",
"common.loading": "Wird geladen…",
"common.optionalForAll": "optional, für alle", "common.optionalForAll": "optional, für alle",
"common.optional": "optional", "common.optional": "optional",
"common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)", "common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)",
@@ -45,7 +46,51 @@ const STRINGS = {
"nav.imported": "Importiert", "nav.imported": "Importiert",
"nav.import": "Import", "nav.import": "Import",
"nav.settings": "Libraries", "nav.settings": "Libraries",
"nav.activity": "Aktivität",
"nav.series": "Serien",
"watch.button": "Beobachten",
"watch.buttonTitle": "Täglich nach neuen Teilen dieser Serie schauen",
"watch.added": "Serie wird beobachtet",
"watch.already": "Diese Serie wird schon beobachtet",
"watch.check": "Jetzt prüfen",
"watch.empty": "Noch keine Serie beobachtet. In der Suche unter „Ganze Serie…“ auf „Beobachten“ klicken.",
"watch.checked": "geprüft {at}",
"watch.error": "Letzte Prüfung fehlgeschlagen: {error}",
"watch.nothingNew": "Nichts Neues.",
"watch.ignore": "Ignorieren",
"watch.remove": "Nicht mehr beobachten",
"watch.confirmRemove": "Diese Serie nicht mehr beobachten?",
"nav.system": "System",
"system.health": "Zustand",
"system.healthy": "Alles in Ordnung.",
"system.version": "Version",
"system.logs": "Letzte Log-Einträge",
"system.level": "Stufe",
"system.source": "Quelle",
"system.message": "Meldung",
"health.download.missing": "Der Download-Ordner {path} existiert nicht.",
"health.download.readonly": "wordarr kann im Download-Ordner {path} nicht schreiben, Importe schlagen fehl.",
"health.library.missing": "Library „{name}“: Der Ordner {path} existiert nicht.",
"health.library.readonly": "Library „{name}“: wordarr kann in {path} nicht schreiben.",
"health.hardcover.token": "Kein Hardcover-Token gesetzt (WORDARR_HARDCOVER_TOKEN), die Ebook-Suche fällt auf Google Books und Open Library zurück.",
"health.abs.incomplete": "Audiobookshelf ist nur halb eingerichtet: WORDARR_ABS_URL und WORDARR_ABS_TOKEN müssen beide gesetzt sein.",
"nav.langToggle": "Switch to English", "nav.langToggle": "Switch to English",
"history.time": "Zeit",
"history.event": "Ereignis",
"history.imported": "importiert",
"history.appended": "ergänzt",
"history.relocated": "verschoben",
"history.retagged": "neu getaggt",
"history.deleted": "gelöscht",
"history.failed": "fehlgeschlagen",
"history.adopted": "übernommen",
"adopt.button": "Einlesen",
"adopt.buttonTitle": "Dateien, die schon in der Library liegen, offenen Anfragen zuordnen",
"adopt.heading": "„{name}“ einlesen",
"adopt.intro": "{n} Dateien passen zu offenen Anfragen dieser Library und bleiben, wo sie sind. {rest} weitere ohne Treffer.",
"adopt.submit": "Übernehmen",
"adopt.done": "{n} übernommen",
"adopt.partial": "{n} übernommen, Fehler: {failed}",
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche", "search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
"search.allLanguages": "Alle Sprachen", "search.allLanguages": "Alle Sprachen",
@@ -96,6 +141,13 @@ const STRINGS = {
"list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?", "list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
"list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.", "list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
"list.deletedN": "{n} entfernt", "list.deletedN": "{n} entfernt",
"list.sort": "Sortierung",
"list.sortNewest": "Neueste zuerst",
"list.sortTitle": "Titel",
"list.sortAuthor": "Autor",
"list.sortSeries": "Serie und Band",
"list.moveTo": "Bibliothek ändern ({n})…",
"list.movedN": "{n} nach {library} verschoben",
"detail.statusMissing": "fehlt noch", "detail.statusMissing": "fehlt noch",
"detail.statusImported": "importiert", "detail.statusImported": "importiert",
@@ -146,6 +198,8 @@ const STRINGS = {
"import.splitDir": "Ordner in einzelne Titel aufteilen", "import.splitDir": "Ordner in einzelne Titel aufteilen",
"import.parts": "{parts} Teile · {files} Dateien", "import.parts": "{parts} Teile · {files} Dateien",
"import.maybeSeparate": "{n} Titel?", "import.maybeSeparate": "{n} Titel?",
"import.folderSeries": "→ {series} #{volume}",
"import.folderSeriesTitle": "Reihe und Band aus dem Ordnernamen ersetzen beim Import die des Eintrags ({series} #{volume})",
"import.maybeSeparateTitle": "Die {n} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen", "import.maybeSeparateTitle": "Die {n} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen",
"import.libraryCellTitle": "Ziel-Library dieser Anfrage ändern", "import.libraryCellTitle": "Ziel-Library dieser Anfrage ändern",
"import.conflict": "⚠ Konflikt", "import.conflict": "⚠ Konflikt",
@@ -254,6 +308,7 @@ const STRINGS = {
"common.status": "Status", "common.status": "Status",
"common.noLibrary": "— no library —", "common.noLibrary": "— no library —",
"common.nothingHere": "Nothing here.", "common.nothingHere": "Nothing here.",
"common.loading": "Loading…",
"common.optionalForAll": "optional, for all", "common.optionalForAll": "optional, for all",
"common.optional": "optional", "common.optional": "optional",
"common.needLibraryFirst": "Create a library for this type first (Libraries tab)", "common.needLibraryFirst": "Create a library for this type first (Libraries tab)",
@@ -266,7 +321,51 @@ const STRINGS = {
"nav.imported": "Imported", "nav.imported": "Imported",
"nav.import": "Import", "nav.import": "Import",
"nav.settings": "Libraries", "nav.settings": "Libraries",
"nav.activity": "Activity",
"nav.series": "Series",
"watch.button": "Watch",
"watch.buttonTitle": "Look for new parts of this series every day",
"watch.added": "Series is being watched",
"watch.already": "This series is already watched",
"watch.check": "Check now",
"watch.empty": "No series watched yet. In the search, open “Whole series…” and click “Watch”.",
"watch.checked": "checked {at}",
"watch.error": "Last check failed: {error}",
"watch.nothingNew": "Nothing new.",
"watch.ignore": "Ignore",
"watch.remove": "Stop watching",
"watch.confirmRemove": "Stop watching this series?",
"nav.system": "System",
"system.health": "Health",
"system.healthy": "All good.",
"system.version": "Version",
"system.logs": "Recent log entries",
"system.level": "Level",
"system.source": "Source",
"system.message": "Message",
"health.download.missing": "The download folder {path} does not exist.",
"health.download.readonly": "wordarr cannot write to the download folder {path}, imports will fail.",
"health.library.missing": "Library “{name}”: the folder {path} does not exist.",
"health.library.readonly": "Library “{name}”: wordarr cannot write to {path}.",
"health.hardcover.token": "No Hardcover token set (WORDARR_HARDCOVER_TOKEN), ebook search falls back to Google Books and Open Library.",
"health.abs.incomplete": "Audiobookshelf is only half set up: WORDARR_ABS_URL and WORDARR_ABS_TOKEN must both be set.",
"nav.langToggle": "Auf Deutsch umschalten", "nav.langToggle": "Auf Deutsch umschalten",
"history.time": "Time",
"history.event": "Event",
"history.imported": "imported",
"history.appended": "appended",
"history.relocated": "relocated",
"history.retagged": "retagged",
"history.deleted": "deleted",
"history.failed": "failed",
"history.adopted": "adopted",
"adopt.button": "Scan",
"adopt.buttonTitle": "Link files already in the library to open requests",
"adopt.heading": "Scan “{name}”",
"adopt.intro": "{n} files match open requests of this library and stay where they are. {rest} more without a match.",
"adopt.submit": "Adopt",
"adopt.done": "{n} adopted",
"adopt.partial": "{n} adopted, errors: {failed}",
"search.languageTitle": "Edition language — filters the ebook and audiobook search", "search.languageTitle": "Edition language — filters the ebook and audiobook search",
"search.allLanguages": "All languages", "search.allLanguages": "All languages",
@@ -317,6 +416,13 @@ const STRINGS = {
"list.confirmDeleteRequests": "Remove {n} request(s)?", "list.confirmDeleteRequests": "Remove {n} request(s)?",
"list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.", "list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
"list.deletedN": "{n} removed", "list.deletedN": "{n} removed",
"list.sort": "Sort order",
"list.sortNewest": "Newest first",
"list.sortTitle": "Title",
"list.sortAuthor": "Author",
"list.sortSeries": "Series and volume",
"list.moveTo": "Change library ({n})…",
"list.movedN": "{n} moved to {library}",
"detail.statusMissing": "still missing", "detail.statusMissing": "still missing",
"detail.statusImported": "imported", "detail.statusImported": "imported",
@@ -367,6 +473,8 @@ const STRINGS = {
"import.splitDir": "Split the folder into single titles", "import.splitDir": "Split the folder into single titles",
"import.parts": "{parts} parts · {files} files", "import.parts": "{parts} parts · {files} files",
"import.maybeSeparate": "{n} titles?", "import.maybeSeparate": "{n} titles?",
"import.folderSeries": "→ {series} #{volume}",
"import.folderSeriesTitle": "The series and volume from the folder name replace the request's on import ({series} #{volume})",
"import.maybeSeparateTitle": "The {n} files carry different titles and are each big enough for a whole book — split with ✂️", "import.maybeSeparateTitle": "The {n} files carry different titles and are each big enough for a whole book — split with ✂️",
"import.libraryCellTitle": "Change this request's target library", "import.libraryCellTitle": "Change this request's target library",
"import.conflict": "⚠ Conflict", "import.conflict": "⚠ Conflict",
+77
View File
@@ -20,7 +20,16 @@
</button> </button>
<button data-view="imported" data-i18n="nav.imported"></button> <button data-view="imported" data-i18n="nav.imported"></button>
<button data-view="import" data-i18n="nav.import"></button> <button data-view="import" data-i18n="nav.import"></button>
<button data-view="series">
<span data-i18n="nav.series"></span>
<span id="watch-badge" class="badge" hidden></span>
</button>
<button data-view="activity" data-i18n="nav.activity"></button>
<button data-view="settings" data-i18n="nav.settings"></button> <button data-view="settings" data-i18n="nav.settings"></button>
<button data-view="system">
<span data-i18n="nav.system"></span>
<span id="health-badge" class="badge" hidden></span>
</button>
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button> <button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
</nav> </nav>
</header> </header>
@@ -92,6 +101,7 @@
</div> </div>
<div class="row"> <div class="row">
<button type="button" id="series-cancel" class="secondary" data-i18n="common.cancel"></button> <button type="button" id="series-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="series-watch" class="secondary" data-i18n="watch.button" data-i18n-title="watch.buttonTitle"></button>
<button type="button" id="series-submit" data-i18n="common.request"></button> <button type="button" id="series-submit" data-i18n="common.request"></button>
</div> </div>
</dialog> </dialog>
@@ -109,7 +119,14 @@
<option value="" data-i18n="list.allLibraries"></option> <option value="" data-i18n="list.allLibraries"></option>
</select> </select>
<input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" /> <input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" />
<select id="missing-sort" data-i18n-title="list.sort">
<option value="newest" data-i18n="list.sortNewest"></option>
<option value="title" data-i18n="list.sortTitle"></option>
<option value="author" data-i18n="list.sortAuthor"></option>
<option value="series" data-i18n="list.sortSeries"></option>
</select>
<button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button> <button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button>
<select id="missing-move-library" hidden></select>
<button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button> <button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view"> <div class="view-toggle" role="group" data-i18n-aria="list.view">
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button> <button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
@@ -123,6 +140,12 @@
<section id="view-imported" hidden> <section id="view-imported" hidden>
<div class="bar wrap"> <div class="bar wrap">
<input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" /> <input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" />
<select id="imported-sort" data-i18n-title="list.sort">
<option value="newest" data-i18n="list.sortNewest"></option>
<option value="title" data-i18n="list.sortTitle"></option>
<option value="author" data-i18n="list.sortAuthor"></option>
<option value="series" data-i18n="list.sortSeries"></option>
</select>
<button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button> <button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button>
<button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button> <button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view"> <div class="view-toggle" role="group" data-i18n-aria="list.view">
@@ -269,6 +292,50 @@
</dialog> </dialog>
</section> </section>
<section id="view-series" hidden>
<div class="bar">
<button type="button" id="watch-check" data-i18n="watch.check"></button>
</div>
<div id="watch-list"></div>
</section>
<section id="view-activity" hidden>
<div class="table-scroll">
<table id="history-table">
<thead>
<tr>
<th data-i18n="history.time"></th>
<th data-i18n="history.event"></th>
<th data-i18n="common.title"></th>
<th data-i18n="common.path"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-system" hidden>
<h3 data-i18n="system.health"></h3>
<ul id="health-list"></ul>
<h3 data-i18n="system.version"></h3>
<p id="system-version" class="mono"></p>
<h3 data-i18n="system.logs"></h3>
<div class="table-scroll">
<table id="log-table">
<thead>
<tr>
<th data-i18n="history.time"></th>
<th data-i18n="system.level"></th>
<th data-i18n="system.source"></th>
<th data-i18n="system.message"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-settings" hidden> <section id="view-settings" hidden>
<h3 data-i18n="lib.heading"></h3> <h3 data-i18n="lib.heading"></h3>
<div class="table-scroll"> <div class="table-scroll">
@@ -349,6 +416,16 @@
</form> </form>
</dialog> </dialog>
<dialog id="adopt-dialog">
<h3 id="adopt-heading"></h3>
<p class="muted" id="adopt-intro"></p>
<div id="adopt-list" class="series-list"></div>
<div class="row">
<button type="button" id="adopt-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="adopt-submit" data-i18n="adopt.submit"></button>
</div>
</dialog>
<div id="toast" hidden></div> <div id="toast" hidden></div>
<script src="/i18n.js"></script> <script src="/i18n.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
+14 -2
View File
@@ -48,6 +48,7 @@ button:disabled { opacity: 0.6; cursor: default; }
} }
.card.clickable { cursor: pointer; transition: border-color 0.1s, background 0.1s; } .card.clickable { cursor: pointer; transition: border-color 0.1s, background 0.1s; }
.card.clickable:hover { border-color: var(--accent); background: #262b35; } .card.clickable:hover { border-color: var(--accent); background: #262b35; }
.card.clickable:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.card img, .nocover { width: 60px; height: 90px; object-fit: cover; border-radius: 4px; flex-shrink: 0; } .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; } .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; } .card-body { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
@@ -73,6 +74,17 @@ td.state { white-space: nowrap; font-size: 0.85rem; }
td.state.done { color: var(--ok); } td.state.done { color: var(--ok); }
td.state.failed { color: #e08585; } td.state.failed { color: #e08585; }
td.state.running { color: var(--muted); } td.state.running { color: var(--muted); }
td.state.warn { color: var(--warn); }
.badge.warn { background: var(--warn); color: #000; }
.badge.error { background: var(--danger); }
.watch { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.9rem; margin-bottom: 0.8rem; }
.watch .row:first-child { margin-top: 0; }
.watch table { margin-top: 0.5rem; }
.watch .error-text { color: #e08585; }
#health-list { list-style: none; margin: 0.5rem 0 1rem; }
#health-list li { background: var(--panel); border-left: 3px solid var(--warn); border-radius: 4px; padding: 0.5rem 0.8rem; margin: 0.3rem 0; }
#health-list li.error { border-left-color: var(--danger); }
#health-list li.ok { border-left-color: var(--ok); }
.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; } .tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; }
/* view modes (Missing/Importiert) */ /* view modes (Missing/Importiert) */
@@ -103,8 +115,8 @@ td select { max-width: 340px; }
.score-none { color: var(--muted); } .score-none { color: var(--muted); }
.volume { width: 90px; } .volume { width: 90px; }
/* dialogs */ /* dialogs; margin: auto undoes the * reset, which pins them to the top left */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); } dialog { margin: auto; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); }
dialog::backdrop { background: rgba(0,0,0,0.6); } 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); flex: 1; } dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5rem 0; font-size: 0.9rem; color: var(--muted); flex: 1; }
.detail-head { display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 0.5rem; } .detail-head { display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 0.5rem; }
+94 -1
View File
@@ -25,6 +25,7 @@ GERMAN_EDITION = {
"isbn_10": None, "isbn_10": None,
"release_date": "1957-03-01", "release_date": "1957-03-01",
"cached_image": "https://example.invalid/hobbit-de.jpg", "cached_image": "https://example.invalid/hobbit-de.jpg",
"language": {"code2": "de"},
} }
@@ -83,6 +84,38 @@ def test_hardcover_drops_books_without_an_edition_in_that_language(monkeypatch):
assert asyncio.run(hardcover.search("hobbit", "german")) == [] assert asyncio.run(hardcover.search("hobbit", "german")) == []
def test_hardcover_keeps_a_book_whose_editions_carry_no_language(monkeypatch):
"""German translations are often their own book row with untagged editions."""
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {
"editions": [{"book_id": 42, "title": "Der Hobbit", "language": None}],
"other": [],
}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
hit = asyncio.run(hardcover.search("hobbit", "german"))[0]
assert (hit.title, hit.language) == ("Der Hobbit", "")
def test_hardcover_drops_an_untagged_edition_of_a_foreign_book(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {
"editions": [{"book_id": 42, "title": "The Hobbit", "language": None}],
"other": [{"book_id": 42}],
}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
assert asyncio.run(hardcover.search("hobbit", "german")) == []
SERIES_ROWS = { SERIES_ROWS = {
"name": "Die drei Fragezeichen", "name": "Die drei Fragezeichen",
"book_series": [ "book_series": [
@@ -129,7 +162,8 @@ def test_hardcover_series_sorts_by_volume_and_keeps_the_first_row(monkeypatch):
def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch): def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch):
calls = series_client(monkeypatch, [ calls = series_client(monkeypatch, [
{"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251", {"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251",
"release_date": "1969-01-01", "cached_image": None}, "release_date": "1969-01-01", "cached_image": None,
"language": {"code2": "de"}},
]) ])
books = asyncio.run(hardcover.series_books("10146", "german")) books = asyncio.run(hardcover.series_books("10146", "german"))
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"} assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
@@ -140,6 +174,46 @@ def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkey
assert [b.title for b in books] == ["Super-Papagei", "Der Phantomsee", "Sonderband"] assert [b.title for b in books] == ["Super-Papagei", "Der Phantomsee", "Sonderband"]
INTRUDER_ROWS = {
"name": "Villains",
"book_series": [
{"position": 1, "book": {
"id": 1, "title": "Vicious", "users_count": 1833,
"contributions": [{"author": {"name": "V. E. Schwab"}}]}},
{"position": 1, "book": {
"id": 2, "title": "Die Schoenste im ganzen Land", "users_count": 1,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
{"position": 2, "book": {
"id": 3, "title": "Coeur de pierre", "users_count": 1,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
{"position": 2, "book": {
"id": 4, "title": "Cold Hearted", "users_count": 76,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
],
}
def test_hardcover_series_skips_a_foreign_book_filed_under_a_volume(monkeypatch):
"""Hardcover lets anyone file a book into a series - "Vicious" sits on volume
1 of the Disney Villains and has a German edition and 1833 readers, so both
tie breakers would hand it the slot."""
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {"editions": [
{"book_id": 1, "title": "Vicious - Das Boese in uns"},
{"book_id": 2, "title": "Die Schoenste im ganzen Land"},
]}})
return httpx.Response(200, json={"data": {"series": [INTRUDER_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
books = asyncio.run(hardcover.series_books("976", "german"))
# volume 2 has no German edition: the most read row beats the French one
assert [b.title for b in books] == [
"Die Schoenste im ganzen Land", "Cold Hearted"]
def test_hardcover_series_survives_a_broken_edition_lookup(monkeypatch): def test_hardcover_series_survives_a_broken_edition_lookup(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
import json import json
@@ -240,3 +314,22 @@ def test_automatic_search_asks_only_the_best_provider(monkeypatch):
monkeypatch.setattr(openlibrary, "search", lambda q, lang="", fb=True: record("ol", q, lang)) monkeypatch.setattr(openlibrary, "search", lambda q, lang="", fb=True: record("ol", q, lang))
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False)) asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
assert called == ["gb"] # no token, so Google Books stands in for Hardcover assert called == ["gb"] # no token, so Google Books stands in for Hardcover
def test_hardcover_puts_hits_of_unknown_language_last(monkeypatch):
untagged = {"document": {**HARDCOVER_HIT["document"], "id": "7", "title": "Bundle"}}
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {"editions": [
{"book_id": 7, "title": "Bundle", "language": None},
GERMAN_EDITION,
]}})
return httpx.Response(200, json={"data": {"search": {"results": {
"hits": [untagged, HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
hits = asyncio.run(hardcover.search("hobbit", "german"))
assert [h.title for h in hits] == ["Der Hobbit", "Bundle"]
+282
View File
@@ -753,6 +753,8 @@ def test_relocate_after_fixing_the_series(client):
# a cover dropped next to the audio must survive the move # a cover dropped next to the audio must survive the move
(Path(dest) / "cover.jpg").write_bytes(b"") (Path(dest) / "cover.jpg").write_bytes(b"")
inode = Path(dest).stat().st_ino
client.put(f"/api/requests/{req_id}", json={ client.put(f"/api/requests/{req_id}", json={
"library_id": lib_id, "title": "Philosopher's Stone", "library_id": lib_id, "title": "Philosopher's Stone",
"series": "Harry Potter", "volume": 1, "series": "Harry Potter", "volume": 1,
@@ -761,6 +763,8 @@ def test_relocate_after_fixing_the_series(client):
assert res["ok"] and res["moved"] assert res["ok"] and res["moved"]
new = root / "Harry Potter" / "Philosopher's Stone" new = root / "Harry Potter" / "Philosopher's Stone"
assert Path(res["dest"]) == new assert Path(res["dest"]) == new
# a renamed folder, not a new one: Audiobookshelf keeps the item and its cover
assert new.stat().st_ino == inode
assert sorted(p.name for p in new.iterdir()) == [ assert sorted(p.name for p in new.iterdir()) == [
"Philosopher's Stone - Part 01.mp3", "Philosopher's Stone - Part 02.mp3", "cover.jpg", "Philosopher's Stone - Part 01.mp3", "Philosopher's Stone - Part 02.mp3", "cover.jpg",
] ]
@@ -769,6 +773,45 @@ def test_relocate_after_fixing_the_series(client):
"imported_path"] == str(new) "imported_path"] == str(new)
def test_the_folder_series_replaces_the_requests_on_import(client):
"""Hardcover counts "Tote Augen" as Will Trent 3, the German release as Georgia 1."""
root = client.tmp_path / "library" / "german"
lib_id = client.post("/api/libraries", json={
"name": "german", "media_type": "audiobook", "root_path": str(root),
"folder_template": "{Series}/{Volume} - {Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Tote Augen", "authors": "Karin Slaughter",
"series": "Will Trent", "volume": 3,
}).json()["id"]
def import_folder(name, **override):
src = client.downloads / name
src.mkdir()
(src / "01.mp3").write_bytes(b"")
return client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": True, "files": [str(src / "01.mp3")],
"request_id": req_id, **override,
}]}).json()["results"][0]
# a failed import must not leave the folder's series behind
(root / "Georgia" / "01 - Tote Augen").mkdir(parents=True)
(root / "Georgia" / "01 - Tote Augen" / "Tote Augen.mp3").write_bytes(b"")
failed = import_folder("broken", series="Georgia", volume=1)
assert not failed["ok"]
req = client.get("/api/requests", params={"status": "missing"}).json()[0]
assert (req["series"], req["volume"]) == ("Will Trent", 3)
shutil.rmtree(root / "Georgia")
result = import_folder("Karin Slaughter - Georgia 01 - Tote Augen",
series="Georgia", volume=1)
assert result["ok"], result
assert Path(result["dest"]).parent == root / "Georgia" / "01 - Tote Augen"
req = client.get("/api/requests", params={"status": "imported"}).json()[0]
assert (req["series"], req["volume"]) == ("Georgia", 1)
def test_relocate_is_a_no_op_when_nothing_changed(client): def test_relocate_is_a_no_op_when_nothing_changed(client):
root = client.tmp_path / "library" / "l" root = client.tmp_path / "library" / "l"
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
@@ -1142,3 +1185,242 @@ def test_version_endpoint_reports_a_build_stamp(client):
assert data["version"] assert data["version"]
# "2026-09-01 15:43" - the newest source file, so a stale deploy stands out # "2026-09-01 15:43" - the newest source file, so a stale deploy stands out
assert len(data["built_at"]) == 16 and data["built_at"][4] == "-" assert len(data["built_at"]) == 16 and data["built_at"][4] == "-"
def test_history_records_what_happened_to_a_request(client):
root = client.tmp_path / "library" / "hist"
lib_id = client.post("/api/libraries", json={
"name": "hist", "media_type": "ebook", "root_path": str(root),
"folder_template": "{Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Momo"}).json()["id"]
gone = client.downloads / "gone.epub"
res = client.post("/api/import", json={"items": [{
"path": str(gone), "is_dir": False, "files": [str(gone)], "request_id": req_id,
}]}).json()["results"][0]
assert not res["ok"]
src = client.downloads / "Momo.epub"
src.write_bytes(b"x")
dest = client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["dest"]
client.put(f"/api/requests/{req_id}", json={"library_id": lib_id, "title": "Momo 2"})
assert client.post(f"/api/requests/{req_id}/relocate").json()["moved"]
# a relocate that moves nothing is not worth a row
client.post(f"/api/requests/{req_id}/relocate")
client.delete(f"/api/requests/{req_id}")
rows = client.get("/api/history").json()
assert [r["event"] for r in rows] == ["deleted", "relocated", "imported", "failed"]
assert all(r["request_id"] == req_id for r in rows)
assert rows[0]["title"] == "Momo 2" # survives the deleted request
assert rows[2]["dest"] == dest
assert rows[3]["message"]
assert rows[0]["at"].endswith("+00:00")
def test_connections_hear_about_committed_rows(client, monkeypatch):
import httpx
from wordarr import connect
calls = []
def handler(request):
calls.append((request.method, str(request.url)))
if request.url.path == "/api/libraries":
return httpx.Response(200, json={"libraries": [
{"id": "lib-audio", "name": "Audio",
"folders": [{"fullPath": str(client.tmp_path / "library")}]},
{"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]},
]})
if request.url.host == "hook":
return httpx.Response(500) # a broken webhook must not stop the scan
return httpx.Response(200)
real_client = httpx.Client
monkeypatch.setattr(connect.httpx, "Client",
lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw))
monkeypatch.setattr(connect, "WEBHOOK_URL", "http://hook/in")
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
monkeypatch.setattr(connect, "ABS_TOKEN", "t")
root = client.tmp_path / "library" / "conn"
lib_id = client.post("/api/libraries", json={
"name": "conn", "media_type": "ebook", "root_path": str(root),
"folder_template": "{Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Momo"}).json()["id"]
src = client.downloads / "Momo.epub"
src.write_bytes(b"x")
assert client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["ok"]
assert calls == [
("POST", "http://hook/in"),
("GET", "http://abs/api/libraries"),
("POST", "http://abs/api/libraries/lib-audio/scan"),
]
calls.clear()
client.delete(f"/api/requests/{req_id}")
assert calls == [("POST", "http://hook/in")] # nothing new on disk, no scan
def test_bulk_move_delete_and_count(client):
def lib(name, media_type):
return client.post("/api/libraries", json={
"name": name, "media_type": media_type,
"root_path": str(client.tmp_path / name)}).json()["id"]
a, b, audio = lib("a", "ebook"), lib("b", "ebook"), lib("audio", "audiobook")
ids = [client.post("/api/requests", json={"library_id": a, "title": f"T{i}"}).json()["id"]
for i in range(3)]
assert client.get("/api/requests/count?status=missing").json() == {"count": 3}
assert client.put("/api/requests/bulk", json={
"ids": ids[:2], "library_id": audio}).status_code == 400
assert client.put("/api/requests/bulk", json={
"ids": ids[:2], "library_id": b}).json() == {"updated": 2}
assert [r["id"] for r in client.get(f"/api/requests?library_id={b}").json()] \
== sorted(ids[:2], reverse=True)
assert client.request("DELETE", "/api/requests/bulk",
json={"ids": ids[1:]}).json() == {"deleted": 2}
assert client.get("/api/requests/count?status=missing").json() == {"count": 1}
assert [r["event"] for r in client.get("/api/history").json()] == ["deleted"] * 2
def test_adopt_files_already_in_the_library(client):
root = client.tmp_path / "audio"
lib = client.post("/api/libraries", json={
"name": "Audio", "media_type": "audiobook", "root_path": str(root)}).json()["id"]
hobbit = client.post("/api/requests", json={
"library_id": lib, "title": "Der Hobbit", "authors": "Tolkien"}).json()["id"]
known = client.post("/api/requests", json={
"library_id": lib, "title": "Momo", "authors": "Ende"}).json()["id"]
for folder in ("Tolkien/Der Hobbit", "Ende/Momo"):
(root / folder).mkdir(parents=True)
for i in (1, 2):
(root / folder / f"{i:02d}.mp3").write_bytes(b"")
(root / "Tolkien" / "Notizen.epub").write_bytes(b"") # other media type
# Momo already points at its folder, so only the Hobbit is offered
adopted = client.post(f"/api/import/library/{lib}", json={"items": [
{"path": str(root / "Ende/Momo"), "request_id": known}]}).json()
assert adopted == {"results": [{"path": str(root / "Ende/Momo"), "ok": True}]}
items = client.get(f"/api/import/library/{lib}").json()["items"]
assert [(i["name"], i["suggested_request_id"]) for i in items] == [("Der Hobbit", hobbit)]
resp = client.post(f"/api/import/library/{lib}", json={"items": [
{"path": items[0]["path"], "request_id": hobbit},
{"path": str(root / "Ende/Momo"), "request_id": known}, # no longer open
{"path": str(client.downloads), "request_id": hobbit},
]}).json()["results"]
assert [r["ok"] for r in resp] == [True, False, False]
reqs = {r["id"]: r for r in client.get("/api/requests?status=imported").json()}
assert reqs[hobbit]["imported_path"] == str(root / "Tolkien/Der Hobbit")
assert (root / "Tolkien/Der Hobbit/01.mp3").exists() # nothing moved
assert client.get(f"/api/import/library/{lib}").json()["items"] == []
assert [h["event"] for h in client.get("/api/history").json()] == ["adopted"] * 2
def test_health_reports_problems_as_codes(client, monkeypatch):
from wordarr import connect
from wordarr.metadata import hardcover
monkeypatch.setattr(hardcover, "TOKEN", "")
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
monkeypatch.setattr(connect, "ABS_TOKEN", "")
gone = client.tmp_path / "gone"
client.post("/api/libraries", json={
"name": "Weg", "media_type": "ebook", "root_path": str(gone)})
client.post("/api/libraries", json={
"name": "Da", "media_type": "ebook", "root_path": str(client.downloads)})
issues = client.get("/api/system/health").json()
assert {(i["code"], i.get("name")) for i in issues} == {
("library.missing", "Weg"), ("hardcover.token", None), ("abs.incomplete", None)}
monkeypatch.setattr(hardcover, "TOKEN", "x")
monkeypatch.setattr(connect, "ABS_URL", "")
gone.mkdir()
assert client.get("/api/system/health").json() == []
def test_recent_logs_newest_first(client):
import logging
logging.getLogger("wordarr.test").info("first")
logging.getLogger("wordarr.test").warning("second")
rows = client.get("/api/system/logs").json()
assert [r["message"] for r in rows[:2]] == ["second", "first"]
assert rows[0]["level"] == "WARNING"
def test_watched_series_suggests_only_new_parts(client, monkeypatch):
import asyncio
import datetime
from wordarr import db
from wordarr.api import series
from wordarr.metadata import SERIES_PROVIDERS
from wordarr.metadata.base import MetadataResult
def part(volume, external_id=""):
return MetadataResult(media_type="ebook", title=f"Band {volume}", volume=volume,
series="Saga", external_id=external_id, source="hardcover")
hits = [part(1, "a1"), part(2, "a2")]
calls = []
async def lookup(series_id, title, language, source):
calls.append(series_id)
if hits is None:
raise RuntimeError("offline")
return hits
monkeypatch.setitem(SERIES_PROVIDERS, "ebook", lookup)
lib = client.post("/api/libraries", json={
"name": "Saga", "media_type": "ebook", "root_path": str(client.tmp_path)}).json()
client.post("/api/requests", json={"library_id": lib["id"], "title": "Band 3", "volume": 3})
watch = {"media_type": "ebook", "library_id": lib["id"], "series_id": "s1",
"source": "hardcover", "title": "Saga"}
resp = client.post("/api/series", json=watch)
assert resp.status_code == 200
assert resp.json()["new"] == [] # what exists today is not news
assert client.post("/api/series", json=watch).status_code == 409
hits += [part(3), part(4, "a4"), part(5, "a5")] # 3 is requested already
[w] = client.post("/api/series/check").json()
new = {p["title"]: p["id"] for p in w["new"]}
assert set(new) == {"Band 4", "Band 5"}
client.post("/api/series/parts", json={"ids": [new["Band 4"]], "action": "request"})
client.post("/api/series/parts", json={"ids": [new["Band 5"]], "action": "ignore"})
missing = client.get("/api/requests?status=missing").json()
assert {r["title"] for r in missing} == {"Band 3", "Band 4"}
assert client.post("/api/series/check").json()[0]["new"] == []
hits = None
[w] = client.post("/api/series/check").json()
assert w["error"] == "offline"
assert client.post("/api/series", json={**watch, "series_id": "s2"}).status_code == 502
# the daily run only picks up what was checked more than a day ago
hits, calls[:] = [], []
asyncio.run(series.check_due())
assert calls == []
with db.SessionLocal() as session:
session.get(db.WatchedSeries, w["id"]).checked_at = (
datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=2))
session.commit()
asyncio.run(series.check_due())
assert calls == ["s1"]
assert client.delete(f"/api/series/{w['id']}").status_code == 200
assert client.get("/api/series").json() == []
+39
View File
@@ -82,3 +82,42 @@ def test_a_broken_fallback_never_breaks_the_search(monkeypatch):
monkeypatch.setattr(metadata.musicbrainz, "search", boom) monkeypatch.setattr(metadata.musicbrainz, "search", boom)
results = asyncio.run(metadata.audiobook_search("Toteninsel")) results = asyncio.run(metadata.audiobook_search("Toteninsel"))
assert [r.title for r in results] == ["Da"] assert [r.title for r in results] == ["Da"]
def test_audiobook_search_appends_hardcover_series_as_audiobooks(monkeypatch):
from wordarr.metadata.base import MetadataResult
async def fake_audible(query, language=""):
return [MetadataResult(media_type="audiobook", title="Die letzte Witwe")]
async def no_hits(query, language=""):
return []
async def fake_hardcover(query, language=""):
return [MetadataResult(media_type="ebook", source="hardcover",
title="Die verstummte Frau", series="Will Trent",
series_id="3670", volume=10)]
monkeypatch.setattr(metadata.audible, "search", fake_audible)
monkeypatch.setattr(metadata.musicbrainz, "search", no_hits)
monkeypatch.setattr(metadata.hardcover, "search", fake_hardcover)
results = asyncio.run(metadata.audiobook_search("Karin Slaughter", "german"))
assert [r.source for r in results] == ["audible", "hardcover"]
assert results[1].media_type == "audiobook"
def test_audiobook_series_goes_to_the_provider_of_the_hit(monkeypatch):
from wordarr.metadata.base import MetadataResult
async def fake_series_books(series_id, language=""):
return [MetadataResult(media_type="ebook", source="hardcover",
title="Triptych", volume=1)]
async def audible_must_not_run(series_id, title):
raise AssertionError("a hardcover id is no audible series asin")
monkeypatch.setattr(metadata.hardcover, "series_books", fake_series_books)
monkeypatch.setattr(metadata.audible, "series_episodes", audible_must_not_run)
lookup = metadata.SERIES_PROVIDERS["audiobook"]
books = asyncio.run(lookup("3670", "Will Trent", "german", "hardcover"))
assert [(b.title, b.media_type) for b in books] == [("Triptych", "audiobook")]
+26
View File
@@ -97,6 +97,8 @@ def test_import_dialog_opens_and_closes(page):
page.click("[data-quick='0']") page.click("[data-quick='0']")
page.wait_for_selector("#quick-dialog[open]") page.wait_for_selector("#quick-dialog[open]")
assert page.locator("#quick-dialog").is_visible() assert page.locator("#quick-dialog").is_visible()
box = page.locator("#quick-dialog").bounding_box()
assert abs(box["x"] + box["width"] / 2 - 600) < 10 # centered in the viewport
# everything reachable, nothing cut off at the bottom # everything reachable, nothing cut off at the bottom
for sel in ("#quick-m-submit", "#quick-close"): for sel in ("#quick-m-submit", "#quick-close"):
box = page.locator(sel).bounding_box() box = page.locator(sel).bounding_box()
@@ -159,3 +161,27 @@ def test_the_header_shows_the_running_version(page):
stamp = page.locator("#app-version").text_content() stamp = page.locator("#app-version").text_content()
assert stamp.startswith("v") assert stamp.startswith("v")
assert len(stamp) > 3 # version plus the build stamp behind it assert len(stamp) > 3 # version plus the build stamp behind it
def test_the_view_survives_reload_and_back(page, server):
page.click("nav button[data-view=missing]")
assert page.url.endswith("#missing")
page.reload(wait_until="networkidle")
assert page.locator("#view-missing").is_visible()
assert not page.locator("#view-search").is_visible()
page.click("nav button[data-view=settings]")
page.go_back()
assert page.locator("#view-missing").is_visible()
assert page.locator("nav button[data-view=missing]").get_attribute("class") == "active"
def test_a_card_opens_from_the_keyboard(page, server, tmp_path):
lib = page.request.post(f"{server}/api/libraries", data={
"name": "Tastatur", "media_type": "ebook", "root_path": str(tmp_path)}).json()
page.request.post(f"{server}/api/requests", data={
"title": "Zyxw Tastaturprobe", "library_id": lib["id"]})
page.click("nav button[data-view=missing]")
card = page.locator("[data-detail]", has_text="Zyxw Tastaturprobe")
card.focus()
page.keyboard.press("Enter")
assert page.locator("#detail-dialog").is_visible()
+36
View File
@@ -0,0 +1,36 @@
import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, field_serializer
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..db import History, get_session
router = APIRouter(prefix="/api/history", tags=["history"])
class HistoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
at: datetime.datetime
event: str
request_id: int | None
title: str
media_type: str
source: str
dest: str
message: str
# SQLite drops the zone, and a bare timestamp reads as local time in JS
@field_serializer("at")
def _utc(self, at: datetime.datetime) -> str:
return at.replace(tzinfo=datetime.UTC).isoformat()
@router.get("", response_model=list[HistoryOut])
def list_history(limit: int = 200, session: Session = Depends(get_session)):
"""Newest first."""
stmt = select(History).order_by(History.id.desc()).limit(min(limit, 1000))
return session.scalars(stmt).all()
+102 -6
View File
@@ -1,13 +1,13 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config from .. import config, connect
from ..db import BookRequest, get_session from ..db import BookRequest, Library, get_session, record
from ..fsnames import decode_path, readable from ..fsnames import decode_path, is_broken, readable
from ..importer import matcher, mover, scanner from ..importer import matcher, mover, scanner
router = APIRouter(prefix="/api/import", tags=["import"]) router = APIRouter(prefix="/api/import", tags=["import"])
@@ -47,6 +47,10 @@ class ImportItem(BaseModel):
request_id: int request_id: int
# add to an already imported audiobook instead of importing a new one # add to an already imported audiobook instead of importing a new one
append: bool = False append: bool = False
# series and volume the download folder names; they win over the request's,
# since translations number their series differently from the metadata APIs
series: str = ""
volume: int | None = None
class ImportIn(BaseModel): class ImportIn(BaseModel):
@@ -54,8 +58,9 @@ class ImportIn(BaseModel):
@router.post("") @router.post("")
def do_import(data: ImportIn, session: Session = Depends(get_session)): def do_import(data: ImportIn, background: BackgroundTasks,
results = [] session: Session = Depends(get_session)):
results, rows = [], []
download_root = Path(config.DOWNLOAD_DIR).resolve() download_root = Path(config.DOWNLOAD_DIR).resolve()
for item in data.items: for item in data.items:
# the client hands back whatever the scan produced, base64 included # the client hands back whatever the scan produced, base64 included
@@ -87,9 +92,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
try: try:
dest = mover.append_to_import(item.files, req, req.library) dest = mover.append_to_import(item.files, req, req.library)
except Exception as exc: except Exception as exc:
rows.append(record(session, "failed", req, source=shown, message=str(exc)))
session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)}) results.append({"path": shown, "ok": False, "error": str(exc)})
continue continue
req.imported_path = dest req.imported_path = dest
rows.append(record(session, "appended", req, source=shown, dest=dest))
session.commit() session.commit()
results.append({"path": shown, "ok": True, "dest": dest, "appended": True}) results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
continue continue
@@ -101,15 +109,103 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
" — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen." " — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen."
)}) )})
continue continue
if item.series.strip() and item.volume is not None:
req.series, req.volume = item.series.strip(), item.volume
try: try:
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library) dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc: except Exception as exc:
session.rollback() # the next commit must not keep the folder's series
rows.append(record(session, "failed", req, source=shown, message=str(exc)))
session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)}) results.append({"path": shown, "ok": False, "error": str(exc)})
continue continue
req.status = "imported" req.status = "imported"
req.imported_path = dest req.imported_path = dest
rows.append(record(session, "imported", req, source=shown, dest=dest))
session.commit() session.commit()
results.append({"path": shown, "ok": True, "dest": dest}) results.append({"path": shown, "ok": True, "dest": dest})
if not results: if not results:
raise HTTPException(400, "nothing to import") raise HTTPException(400, "nothing to import")
connect.schedule(background, rows)
return {"results": results}
def _library(session: Session, library_id: int) -> Library:
lib = session.get(Library, library_id)
if not lib:
raise HTTPException(404, "library not found")
return lib
def _open_for(session: Session, lib: Library) -> list[BookRequest]:
missing = session.scalars(select(BookRequest).where(
BookRequest.status == "missing", BookRequest.library_id == lib.id)).all()
return list(missing) + [r for r in _orphaned(session) if r.library_id == lib.id]
@router.get("/library/{library_id}")
def scan_library(library_id: int, session: Session = Depends(get_session)):
"""Files already in the library that no request points at, matched against
the library's open requests, so an existing collection need not be imported
again."""
lib = _library(session, library_id)
imported = session.scalars(select(BookRequest.imported_path).where(
BookRequest.status == "imported", BookRequest.imported_path != "")).all()
covered = {Path(p) for p in imported}
# a folder holding an imported file, or a file inside an imported folder
above = {a for p in covered for a in p.parents}
def known(item: dict) -> bool:
p = Path(decode_path(item["path"]))
return p in covered or p in above or any(a in covered for a in p.parents)
items = [i for i in scanner.scan(Path(lib.root_path))
if i["media_type"] == lib.media_type and not known(i)]
open_reqs = _open_for(session, lib)
return {"root_path": lib.root_path,
"items": matcher.best_matches(items, open_reqs),
"requests": [{"id": r.id, "title": r.title, "authors": r.authors}
for r in open_reqs]}
class AdoptItem(BaseModel):
path: str
request_id: int
class AdoptIn(BaseModel):
items: list[AdoptItem]
@router.post("/library/{library_id}")
def adopt(library_id: int, data: AdoptIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Point open requests at files that are already in place. Nothing moves."""
lib = _library(session, library_id)
root = Path(lib.root_path).resolve()
open_ids = {r.id for r in _open_for(session, lib)}
results, rows = [], []
for item in data.items:
path = decode_path(item.path)
shown = readable(path)
req = session.get(BookRequest, item.request_id)
if is_broken(path):
error = "file name is not valid UTF-8, rename it first"
elif root not in Path(path).resolve().parents:
error = "path outside the library"
elif not Path(path).exists():
error = "path no longer exists"
elif not req or req.id not in open_ids:
error = f"request #{item.request_id} is not open in this library"
else:
error = ""
if error:
results.append({"path": shown, "ok": False, "error": error})
continue
req.status, req.imported_path = "imported", path
rows.append(record(session, "adopted", req, dest=path))
open_ids.discard(req.id) # two files for one request: the first wins
results.append({"path": shown, "ok": True})
session.commit()
connect.schedule(background, rows)
return {"results": results} return {"results": results}
+2 -3
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,8 +21,7 @@ class LibraryIn(BaseModel):
class LibraryOut(LibraryIn): class LibraryOut(LibraryIn):
id: int id: int
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
def _validate(data: LibraryIn): def _validate(data: LibraryIn):
+67 -10
View File
@@ -1,12 +1,12 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config from .. import config, connect
from ..db import BookRequest, Library, get_session from ..db import BookRequest, Library, get_session, record
from ..importer import mover, tagger from ..importer import mover, tagger
router = APIRouter(prefix="/api/requests", tags=["requests"]) router = APIRouter(prefix="/api/requests", tags=["requests"])
@@ -34,8 +34,7 @@ class RequestOut(RequestIn):
imported_path: str imported_path: str
library_name: str = "" library_name: str = ""
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
def _out(r: BookRequest) -> RequestOut: def _out(r: BookRequest) -> RequestOut:
@@ -177,6 +176,51 @@ def create_bulk_items(data: BulkItemsIn, session: Session = Depends(get_session)
return {"created": [_out(r) for r in created], "skipped": skipped} return {"created": [_out(r) for r in created], "skipped": skipped}
@router.get("/count")
def count_requests(status: str | None = None, session: Session = Depends(get_session)):
stmt = select(func.count()).select_from(BookRequest)
if status:
stmt = stmt.where(BookRequest.status == status)
return {"count": session.scalar(stmt)}
class BulkIdsIn(BaseModel):
ids: list[int]
class BulkUpdateIn(BulkIdsIn):
library_id: int
# registered before the /{request_id} routes, which would try "bulk" as an id
@router.put("/bulk")
def update_bulk(data: BulkUpdateIn, session: Session = Depends(get_session)):
"""Move requests to another library. Imported files stay where they are
until the request is relocated."""
lib = session.get(Library, data.library_id)
if not lib:
raise HTTPException(404, "library not found")
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
if any(r.media_type != lib.media_type for r in reqs):
raise HTTPException(400, "library has a different media type")
for r in reqs:
r.library_id = lib.id
session.commit()
return {"updated": len(reqs)}
@router.delete("/bulk")
def delete_bulk(data: BulkIdsIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
rows = [record(session, "deleted", r, source=r.imported_path) for r in reqs]
for r in reqs:
session.delete(r)
session.commit()
connect.schedule(background, rows)
return {"deleted": len(reqs)}
@router.put("/{request_id}", response_model=RequestOut) @router.put("/{request_id}", response_model=RequestOut)
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)): def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)
@@ -194,7 +238,8 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends(
@router.post("/{request_id}/retag") @router.post("/{request_id}/retag")
def retag_request(request_id: int, session: Session = Depends(get_session)): def retag_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Rewrite the audio tags of an already imported request from its current """Rewrite the audio tags of an already imported request from its current
metadata (e.g. after adding series/volume/narrator later).""" metadata (e.g. after adding series/volume/narrator later)."""
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)
@@ -214,15 +259,22 @@ def retag_request(request_id: int, session: Session = Depends(get_session)):
raise HTTPException(404, f"no audio files in {path}") raise HTTPException(404, f"no audio files in {path}")
for i, f in enumerate(files, 1): for i, f in enumerate(files, 1):
tagger.tag_audio(f, req, track=i, total=len(files)) tagger.tag_audio(f, req, track=i, total=len(files))
row = record(session, "retagged", req, dest=str(path))
session.commit()
connect.schedule(background, [row])
return {"ok": True, "files": len(files)} return {"ok": True, "files": len(files)}
if path.is_file(): if path.is_file():
tagger.tag_audio(path, req) tagger.tag_audio(path, req)
row = record(session, "retagged", req, dest=str(path))
session.commit()
connect.schedule(background, [row])
return {"ok": True, "files": 1} return {"ok": True, "files": 1}
raise HTTPException(404, f"imported path no longer exists: {path}") raise HTTPException(404, f"imported path no longer exists: {path}")
@router.post("/{request_id}/relocate") @router.post("/{request_id}/relocate")
def relocate_request(request_id: int, session: Session = Depends(get_session)): def relocate_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Re-apply the library's naming scheme to an imported title, e.g. after """Re-apply the library's naming scheme to an imported title, e.g. after
fixing a series that Audible had filed differently.""" fixing a series that Audible had filed differently."""
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)
@@ -238,15 +290,20 @@ def relocate_request(request_id: int, session: Session = Depends(get_session)):
except FileExistsError as exc: except FileExistsError as exc:
raise HTTPException(409, str(exc)) raise HTTPException(409, str(exc))
req.imported_path = dest req.imported_path = dest
rows = [record(session, "relocated", req, source=old, dest=dest)] if dest != old else []
session.commit() session.commit()
connect.schedule(background, rows)
return {"ok": True, "moved": dest != old, "from": old, "dest": dest} return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
@router.delete("/{request_id}") @router.delete("/{request_id}")
def delete_request(request_id: int, session: Session = Depends(get_session)): def delete_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)
if not req: if not req:
raise HTTPException(404, "request not found") raise HTTPException(404, "request not found")
row = record(session, "deleted", req, source=req.imported_path)
session.delete(req) session.delete(req)
session.commit() session.commit()
connect.schedule(background, [row])
return {"ok": True} return {"ok": True}
+4 -3
View File
@@ -19,15 +19,16 @@ async def search(media_type: str, q: str, language: str = "", fallback: bool = T
@router.get("/series", response_model=list[MetadataResult]) @router.get("/series", response_model=list[MetadataResult])
async def series(series_id: str, title: str = "", media_type: str = "audiobook", async def series(series_id: str, title: str = "", media_type: str = "audiobook",
language: str = ""): language: str = "", source: str = ""):
"""All parts of a series, by number. Audible has no series endpoint, so """All parts of a series, by number. Audible has no series endpoint, so
audiobooks are a keyword crawl that may miss episodes; Hardcover is exact.""" audiobooks are a keyword crawl that may miss episodes; Hardcover is exact.
The source picks the provider, a series id only means something to its own."""
lookup = SERIES_PROVIDERS.get(media_type) lookup = SERIES_PROVIDERS.get(media_type)
if not lookup: if not lookup:
raise HTTPException(400, f"no series lookup for media_type: {media_type}") raise HTTPException(400, f"no series lookup for media_type: {media_type}")
if not series_id: if not series_id:
raise HTTPException(400, "series_id required") raise HTTPException(400, "series_id required")
try: try:
return await lookup(series_id, title, language) return await lookup(series_id, title, language, source)
except Exception as exc: except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}") raise HTTPException(502, f"metadata provider error: {exc}")
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import datetime
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from .. import db
from ..db import BookRequest, Library, SeriesPart, WatchedSeries, get_session
from ..metadata import SERIES_PROVIDERS
router = APIRouter(prefix="/api/series", tags=["series"])
log = logging.getLogger(__name__)
PART_FIELDS = ("title", "authors", "narrator", "external_id", "year", "series",
"volume", "cover_url")
def _now() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC)
def _key(r) -> str:
return r.external_id or f"{r.title.strip().lower()}|{r.volume}"
async def check_watch(session: Session, w: WatchedSeries) -> None:
"""Look the series up again. The first lookup only records what exists, so
just later parts become suggestions; so do parts nobody has requested yet."""
try:
hits = await SERIES_PROVIDERS[w.media_type](w.series_id, w.title, w.language, w.source)
except Exception as exc:
log.warning("series %r lookup failed: %s", w.title, exc)
w.error, w.checked_at = str(exc), _now()
return
initial = not w.parts
reqs = session.scalars(select(BookRequest).where(BookRequest.media_type == w.media_type)).all()
requested = {r.external_id for r in reqs if r.external_id}
requested |= {(r.title.strip().lower(), r.volume) for r in reqs}
seen = {p.key for p in w.parts}
for hit in hits:
if (key := _key(hit)) in seen:
continue
seen.add(key)
known = initial or hit.external_id in requested or (
hit.title.strip().lower(), hit.volume) in requested
w.parts.append(SeriesPart(key=key, state="known" if known else "new",
**{f: getattr(hit, f) for f in PART_FIELDS}))
w.error, w.checked_at = "", _now()
async def check_due() -> None:
cutoff = (_now() - datetime.timedelta(days=1)).replace(tzinfo=None) # SQLite stores naive
with db.SessionLocal() as session:
due = session.scalars(select(WatchedSeries).where(
or_(WatchedSeries.checked_at.is_(None), WatchedSeries.checked_at < cutoff))).all()
for w in due:
await check_watch(session, w)
session.commit()
if due:
log.info("checked %d watched series", len(due))
async def run_daily() -> None:
"""Wakes hourly and checks what is a day old, so restarts do not reset the clock."""
while True:
try:
await check_due()
except Exception:
log.exception("series check failed")
await asyncio.sleep(3600)
def _out(w: WatchedSeries) -> dict:
checked = w.checked_at and w.checked_at.replace(tzinfo=datetime.UTC).isoformat()
return {
"id": w.id, "media_type": w.media_type, "library_id": w.library_id,
"library_name": w.library.name, "series_id": w.series_id, "source": w.source,
"title": w.title, "checked_at": checked, "error": w.error,
"new": [{"id": p.id, **{f: getattr(p, f) for f in PART_FIELDS}}
for p in w.parts if p.state == "new"],
}
def _all(session: Session) -> list[dict]:
return [_out(w) for w in session.scalars(select(WatchedSeries).order_by(WatchedSeries.title))]
@router.get("")
def list_watches(session: Session = Depends(get_session)):
return _all(session)
class WatchIn(BaseModel):
media_type: str
library_id: int
series_id: str
source: str = ""
title: str
language: str = ""
@router.post("")
async def create_watch(data: WatchIn, session: Session = Depends(get_session)):
if data.media_type not in SERIES_PROVIDERS:
raise HTTPException(400, f"no series lookup for media_type: {data.media_type}")
lib = session.get(Library, data.library_id)
if not lib:
raise HTTPException(404, "library not found")
if lib.media_type != data.media_type:
raise HTTPException(400, "library has a different media type")
if session.scalar(select(WatchedSeries).where(
WatchedSeries.media_type == data.media_type,
WatchedSeries.source == data.source,
WatchedSeries.series_id == data.series_id)):
raise HTTPException(409, "series is already watched")
w = WatchedSeries(**data.model_dump())
session.add(w)
await check_watch(session, w)
if w.error: # without a first lookup every part would turn up as new later
session.rollback()
raise HTTPException(502, f"metadata provider error: {w.error}")
session.commit()
return _out(w)
@router.delete("/{watch_id}")
def delete_watch(watch_id: int, session: Session = Depends(get_session)):
w = session.get(WatchedSeries, watch_id)
if not w:
raise HTTPException(404, "series not found")
session.delete(w)
session.commit()
return {"ok": True}
@router.post("/check")
async def check_all(session: Session = Depends(get_session)):
for w in session.scalars(select(WatchedSeries)).all():
await check_watch(session, w)
session.commit()
return _all(session)
class PartsIn(BaseModel):
ids: list[int]
action: str # request | ignore
@router.post("/parts")
def handle_parts(data: PartsIn, session: Session = Depends(get_session)):
if data.action not in ("request", "ignore"):
raise HTTPException(400, f"invalid action: {data.action}")
parts = session.scalars(select(SeriesPart).where(
SeriesPart.id.in_(data.ids), SeriesPart.state == "new")).all()
for p in parts:
if data.action == "request":
session.add(BookRequest(media_type=p.watch.media_type, library_id=p.watch.library_id,
**{f: getattr(p, f) for f in PART_FIELDS}))
p.state = "requested" if data.action == "request" else "ignored"
session.commit()
return {"done": len(parts)}
+44
View File
@@ -0,0 +1,44 @@
import os
from pathlib import Path
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config, connect, logs
from ..db import Library, get_session
from ..metadata import hardcover
router = APIRouter(prefix="/api/system", tags=["system"])
def _dir_problem(path: Path) -> str | None:
if not path.is_dir():
return "missing"
if not os.access(path, os.W_OK | os.X_OK):
return "readonly"
return None
@router.get("/health")
def health(session: Session = Depends(get_session)):
"""Problems only, as codes the UI translates; an empty list means healthy."""
issues = []
if problem := _dir_problem(config.DOWNLOAD_DIR):
issues.append({"code": f"download.{problem}", "level": "error",
"path": str(config.DOWNLOAD_DIR)})
for lib in session.scalars(select(Library).order_by(Library.name)):
if problem := _dir_problem(Path(lib.root_path)):
issues.append({"code": f"library.{problem}", "level": "error",
"name": lib.name, "path": lib.root_path})
if not hardcover.TOKEN:
issues.append({"code": "hardcover.token", "level": "warning"})
if bool(connect.ABS_URL) != bool(connect.ABS_TOKEN):
issues.append({"code": "abs.incomplete", "level": "warning"})
return issues
@router.get("/logs")
def recent_logs():
"""Newest first."""
return list(reversed(logs.RECORDS))
+60
View File
@@ -0,0 +1,60 @@
"""Tell other services about history rows once they are committed: a generic
webhook gets every row, Audiobookshelf a scan of the libraries holding new
files. Runs as a background task, so a slow or dead service never fails the
request that caused it; errors only reach the log."""
import logging
import os
import httpx
from fastapi import BackgroundTasks
from .db import History
ABS_URL = os.environ.get("WORDARR_ABS_URL", "").strip().rstrip("/")
ABS_TOKEN = os.environ.get("WORDARR_ABS_TOKEN", "").strip()
WEBHOOK_URL = os.environ.get("WORDARR_WEBHOOK_URL", "").strip()
log = logging.getLogger(__name__)
def schedule(background: BackgroundTasks, rows: list[History]) -> None:
"""Queue the notification; it runs after the response is sent."""
events = [
{"event": r.event, "request_id": r.request_id, "title": r.title,
"media_type": r.media_type, "source": r.source, "dest": r.dest,
"message": r.message}
for r in rows
]
if events:
background.add_task(notify, events)
def notify(events: list[dict]) -> None:
with httpx.Client(timeout=10) as client:
if WEBHOOK_URL:
for e in events:
try:
client.post(WEBHOOK_URL, json=e).raise_for_status()
except httpx.HTTPError as exc:
log.warning("webhook %s failed: %s", WEBHOOK_URL, exc)
dests = [e["dest"] for e in events if e["dest"] and e["event"] != "failed"]
if ABS_URL and ABS_TOKEN and dests:
try:
_scan_audiobookshelf(client, dests)
except httpx.HTTPError as exc:
log.warning("audiobookshelf scan failed: %s", exc)
def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None:
"""Scan only the libraries whose folders contain a destination. That needs
both containers to mount the media under the same path."""
headers = {"Authorization": f"Bearer {ABS_TOKEN}"}
resp = client.get(f"{ABS_URL}/api/libraries", headers=headers)
resp.raise_for_status()
for lib in resp.json().get("libraries") or []:
folders = [str(f.get("fullPath") or "").rstrip("/")
for f in lib.get("folders") or []]
if any(f and (d == f or d.startswith(f + "/")) for f in folders for d in dests):
client.post(f"{ABS_URL}/api/libraries/{lib.get('id')}/scan",
headers=headers).raise_for_status()
log.info("audiobookshelf scans library %s", lib.get("name"))
+71
View File
@@ -23,6 +23,8 @@ class Library(Base):
language: Mapped[str] = mapped_column(String, default="") language: Mapped[str] = mapped_column(String, default="")
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library") requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
watches: Mapped[list["WatchedSeries"]] = relationship(
back_populates="library", cascade="all, delete-orphan")
class BookRequest(Base): class BookRequest(Base):
@@ -48,6 +50,75 @@ class BookRequest(Base):
library: Mapped[Library] = relationship(back_populates="requests") library: Mapped[Library] = relationship(back_populates="requests")
class History(Base):
"""One row per thing done to a request. Title and type are copied, and
there is no foreign key, so an entry outlives a deleted request."""
__tablename__ = "history"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime.datetime] = mapped_column(
default=lambda: datetime.datetime.now(datetime.UTC)
)
# imported | appended | relocated | retagged | deleted | failed
event: Mapped[str] = mapped_column(String)
request_id: Mapped[int | None] = mapped_column(default=None)
title: Mapped[str] = mapped_column(String)
media_type: Mapped[str] = mapped_column(String)
source: Mapped[str] = mapped_column(String, default="")
dest: Mapped[str] = mapped_column(String, default="")
message: Mapped[str] = mapped_column(String, default="")
class WatchedSeries(Base):
"""A series looked up again now and then; new parts become suggestions."""
__tablename__ = "watched_series"
id: Mapped[int] = mapped_column(primary_key=True)
media_type: Mapped[str] = mapped_column(String)
library_id: Mapped[int] = mapped_column(ForeignKey("libraries.id"))
series_id: Mapped[str] = mapped_column(String)
source: Mapped[str] = mapped_column(String, default="")
title: Mapped[str] = mapped_column(String)
language: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[datetime.datetime | None] = mapped_column(default=None)
error: Mapped[str] = mapped_column(String, default="")
library: Mapped[Library] = relationship(back_populates="watches")
parts: Mapped[list["SeriesPart"]] = relationship(
back_populates="watch", cascade="all, delete-orphan")
class SeriesPart(Base):
"""Every part a watch has seen, so a later lookup can tell what is new."""
__tablename__ = "series_parts"
id: Mapped[int] = mapped_column(primary_key=True)
watch_id: Mapped[int] = mapped_column(ForeignKey("watched_series.id"))
key: Mapped[str] = mapped_column(String)
state: Mapped[str] = mapped_column(String) # known | new | requested | ignored
title: Mapped[str] = mapped_column(String)
authors: Mapped[str] = mapped_column(String, default="")
narrator: Mapped[str] = mapped_column(String, default="")
external_id: Mapped[str] = mapped_column(String, default="")
year: Mapped[int | None] = mapped_column(default=None)
series: Mapped[str] = mapped_column(String, default="")
volume: Mapped[int | None] = mapped_column(default=None)
cover_url: Mapped[str] = mapped_column(String, default="")
watch: Mapped[WatchedSeries] = relationship(back_populates="parts")
def record(session, event: str, req: BookRequest, **fields: str) -> History:
"""Add a history row; it is saved with the caller's next commit."""
row = History(event=event, request_id=req.id, title=req.title,
media_type=req.media_type, **fields)
session.add(row)
return row
_engine = None _engine = None
SessionLocal = None SessionLocal = None
+14
View File
@@ -126,6 +126,18 @@ def relocate(request, library) -> str:
_remove_if_empty(src.parent, root) _remove_if_empty(src.parent, root)
return str(dest) return str(dest)
# one rename keeps the folder's inode, so Audiobookshelf sees a move and keeps
# the item; filling a new folder file by file makes it add a new item halfway
# and cache whatever cover it saw at that moment
moved_from = None
if not folder.exists():
folder.parent.mkdir(parents=True, exist_ok=True)
try:
src.rename(folder)
moved_from, src = src, folder
except OSError:
pass # another filesystem, or a target inside the source: file by file
audio = sorted( audio = sorted(
(f for f in src.iterdir() (f for f in src.iterdir()
if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS), if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS),
@@ -173,6 +185,8 @@ def relocate(request, library) -> str:
shutil.move(str(extra), target) shutil.move(str(extra), target)
if not same_folder: if not same_folder:
_remove_if_empty(src, root) _remove_if_empty(src, root)
if moved_from:
_remove_if_empty(moved_from.parent, root)
return str(folder) return str(folder)
+30
View File
@@ -0,0 +1,30 @@
"""Keep the latest log records in memory for the system page."""
import collections
import datetime
import logging
# shortcut: lost on restart, write to /config if older entries are ever needed
RECORDS: collections.deque[dict] = collections.deque(maxlen=200)
class _Buffer(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
RECORDS.append({
"at": datetime.datetime.fromtimestamp(record.created, datetime.UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": self.format(record),
})
def install() -> None:
"""A handler on root would silence the last-resort stderr output, so
basicConfig keeps the container log. uvicorn's loggers do not propagate,
its errors are caught on uvicorn.error itself."""
if any(isinstance(h, _Buffer) for h in logging.getLogger().handlers):
return
logging.basicConfig(format="%(levelname)s:%(name)s: %(message)s")
logging.getLogger("wordarr").setLevel(logging.INFO)
buffer = _Buffer(logging.INFO)
logging.getLogger().addHandler(buffer)
logging.getLogger("uvicorn.error").addHandler(buffer)
+10 -2
View File
@@ -1,17 +1,22 @@
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import db, version from . import db, logs, version
from .api import fs, imports, libraries, requests, search from .api import fs, history, imports, libraries, requests, search, series, system
logs.install()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
db.init_db() db.init_db()
watcher = asyncio.create_task(series.run_daily())
yield yield
watcher.cancel()
app = FastAPI(title="wordarr", lifespan=lifespan) app = FastAPI(title="wordarr", lifespan=lifespan)
@@ -21,6 +26,9 @@ app.include_router(search.router)
app.include_router(requests.router) app.include_router(requests.router)
app.include_router(imports.router) app.include_router(imports.router)
app.include_router(fs.router) app.include_router(fs.router)
app.include_router(history.router)
app.include_router(system.router)
app.include_router(series.router)
@app.get("/api/version") @app.get("/api/version")
+27 -12
View File
@@ -58,38 +58,53 @@ async def ebook_search(query: str, language: str = "",
return _merge(groups, _ebook_key) return _merge(groups, _ebook_key)
def _as_audiobook(hits: list[MetadataResult]) -> list[MetadataResult]:
return [r.model_copy(update={"media_type": "audiobook"}) for r in hits]
async def audiobook_search(query: str, language: str = "", async def audiobook_search(query: str, language: str = "",
fallback: bool = True) -> list[MetadataResult]: fallback: bool = True) -> list[MetadataResult]:
"""Audible first, MusicBrainz alongside it. """Audible first, MusicBrainz and Hardcover alongside it.
Audible answers almost any query with *something*, so "no good hit" cannot be Audible answers almost any query with *something*, so "no good hit" cannot be
detected reliably - measuring it showed the fallback would never fire. Both detected reliably - measuring it showed the fallback would never fire. All
are therefore asked in parallel and the MusicBrainz hits are appended, marked are therefore asked in parallel and the extra hits are appended, marked with
with their source. Callers that search automatically (the import dialog when their source. Hardcover is there for its series: Audible leaves most volumes
it opens) pass fallback=False to spare the rate limited service. of a series untagged. Callers that search automatically (the import dialog
when it opens) pass fallback=False to spare the rate limited services.
""" """
if not fallback: if not fallback:
return await audible.search(query, language) return await audible.search(query, language)
hits, extra = await asyncio.gather( hits, *extras = await asyncio.gather(
audible.search(query, language), audible.search(query, language),
musicbrainz.search(query, language), musicbrainz.search(query, language),
hardcover.search(query, language),
return_exceptions=True, return_exceptions=True,
) )
if isinstance(hits, BaseException): if isinstance(hits, BaseException):
raise hits raise hits
if isinstance(extra, BaseException): # a slow fallback must not break search
log.warning("musicbrainz lookup failed for %r: %s", query, extra)
return hits
seen = {(r.title.strip().lower(), r.volume) for r in hits} seen = {(r.title.strip().lower(), r.volume) for r in hits}
return hits + [r for r in extra if (r.title.strip().lower(), r.volume) not in seen] for module, extra in zip((musicbrainz, hardcover), extras):
if isinstance(extra, BaseException): # a slow fallback must not break search
log.warning("%s lookup failed for %r: %s", module.__name__, query, extra)
continue
for r in _as_audiobook(extra):
if (key := (r.title.strip().lower(), r.volume)) not in seen:
seen.add(key)
hits.append(r)
return hits
async def _audiobook_series(series_id: str, title: str, language: str = ""): async def _audiobook_series(series_id: str, title: str, language: str = "",
source: str = ""):
if source == "hardcover":
return _as_audiobook(await hardcover.series_books(series_id, language))
return await audible.series_episodes(series_id, title) return await audible.series_episodes(series_id, title)
async def _ebook_series(series_id: str, title: str = "", language: str = ""): async def _ebook_series(series_id: str, title: str = "", language: str = "",
source: str = ""):
return await hardcover.series_books(series_id, language) return await hardcover.series_books(series_id, language)
+83 -14
View File
@@ -13,6 +13,7 @@ Series are one query - unlike Audible, Hardcover knows them.
import logging import logging
import os import os
from collections import Counter
import httpx import httpx
@@ -37,7 +38,10 @@ query Search($q: String!, $per: Int!) {
_EDITIONS = """ _EDITIONS = """
query Editions($ids: [Int!]!, $lang: String!) { query Editions($ids: [Int!]!, $lang: String!) {
editions( editions(
where: {book_id: {_in: $ids}, language: {code2: {_eq: $lang}}} where: {
book_id: {_in: $ids}
_or: [{language: {code2: {_eq: $lang}}}, {language_id: {_is_null: true}}]
}
order_by: {users_count: desc_nulls_last} order_by: {users_count: desc_nulls_last}
) { ) {
book_id book_id
@@ -46,6 +50,13 @@ query Editions($ids: [Int!]!, $lang: String!) {
isbn_10 isbn_10
release_date release_date
cached_image cached_image
language { code2 }
}
other: editions(
where: {book_id: {_in: $ids}, language: {code2: {_neq: $lang}}}
distinct_on: book_id
) {
book_id
} }
} }
""" """
@@ -61,6 +72,7 @@ query Series($id: Int!, $limit: Int!) {
id id
title title
release_year release_year
users_count
cached_image cached_image
contributions(limit: 3) { author { name } } contributions(limit: 3) { author { name } }
} }
@@ -148,32 +160,49 @@ def _to_result(doc: dict, language: str) -> MetadataResult:
async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult], async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult],
language: str) -> dict[int, MetadataResult]: language: str) -> dict[int, MetadataResult]:
"""The hits that have an edition in the wanted language, swapped in.""" """The hits that have an edition in the wanted language, swapped in.
data = await _post(client, _EDITIONS,
{"ids": list(results), "lang": LANGUAGES[language]}) An edition without a language counts too, unless the book has one in another
localized: dict[int, MetadataResult] = {} language: translations are often a book row of their own whose editions nobody
tagged ("Tote Augen"), while an English row always has tagged editions.
"""
code = LANGUAGES[language]
data = await _post(client, _EDITIONS, {"ids": list(results), "lang": code})
foreign = {ed.get("book_id") for ed in data.get("other") or []}
known: dict[int, MetadataResult] = {}
unknown: dict[int, MetadataResult] = {}
for ed in data.get("editions") or []: for ed in data.get("editions") or []:
book_id = ed.get("book_id") book_id = ed.get("book_id")
base = results.get(book_id) base = results.get(book_id)
if base is None or book_id in localized: if base is None:
continue
if (ed.get("language") or {}).get("code2") == code:
target, tag = known, language
elif book_id not in foreign:
target, tag = unknown, ""
else:
continue
if book_id in target:
continue # the first edition is the most read one continue # the first edition is the most read one
release = str(ed.get("release_date") or "") release = str(ed.get("release_date") or "")
localized[book_id] = base.model_copy(update={ target[book_id] = base.model_copy(update={
"title": ed.get("title") or base.title, "title": ed.get("title") or base.title,
"external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10")) "external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10"))
or base.external_id), or base.external_id),
"year": int(release[:4]) if release[:4].isdigit() else base.year, "year": int(release[:4]) if release[:4].isdigit() else base.year,
"language": language, "language": tag,
"cover_url": _image_url(ed.get("cached_image")) or base.cover_url, "cover_url": _image_url(ed.get("cached_image")) or base.cover_url,
}) })
return localized return {**unknown, **known}
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult], async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult],
language: str) -> list[MetadataResult]: language: str) -> list[MetadataResult]:
"""Replace each hit with its edition in the wanted language, drop the rest.""" """Replace each hit with its edition in the wanted language, drop the rest.
Hits whose language is only unknown go last."""
localized = await _localized(client, results, language) localized = await _localized(client, results, language)
return [localized[book_id] for book_id in results if book_id in localized] hits = [localized[book_id] for book_id in results if book_id in localized]
return sorted(hits, key=lambda r: not r.language)
async def search(query: str, language: str = "", async def search(query: str, language: str = "",
@@ -204,10 +233,38 @@ async def search(query: str, language: str = "",
return list(results.values()) return list(results.values())
def _count(value) -> int:
try:
return int(str(value))
except (TypeError, ValueError):
return 0
def _author_set(book: dict) -> set[str]:
return {name.strip().lower()
for name in _authors(book).split(", ") if name.strip()}
def _main_author(authors: dict[int, set[str]]) -> str:
"""The name behind the series, or "" when the rows do not agree on one.
Translators and publishers ride along in the contributions, so the author of
the series is simply the name most rows share - and a row without it is the
foreign book that someone filed under this position by mistake.
"""
tally = Counter(name for names in authors.values() for name in names)
if not tally:
return ""
name, shared = tally.most_common(1)[0]
return name if shared * 2 > len(authors) else ""
async def series_books(series_id: str, language: str = "") -> list[MetadataResult]: async def series_books(series_id: str, language: str = "") -> list[MetadataResult]:
"""Every volume of a series, by position. A position often holds several book """Every volume of a series, by position. A position often holds several book
rows (the same story as another work); the one with an edition in the wanted rows - other editions of the story, and now and then a foreign book someone
language wins the slot, else the first. Unnumbered books go last.""" filed here by mistake. The slot goes to the row that is by the author of the
series, has an edition in the wanted language and is the most read of those.
Unnumbered books go last."""
if not TOKEN: if not TOKEN:
return [] return []
try: try:
@@ -223,6 +280,8 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul
slots: dict[int, list[int]] = {} # volume -> book rows filed under it slots: dict[int, list[int]] = {} # volume -> book rows filed under it
loose: list[int] = [] loose: list[int] = []
books: dict[int, MetadataResult] = {} books: dict[int, MetadataResult] = {}
authors: dict[int, set[str]] = {}
readers: dict[int, int] = {}
for entry in row.get("book_series") or []: for entry in row.get("book_series") or []:
book = entry.get("book") or {} book = entry.get("book") or {}
try: try:
@@ -238,6 +297,8 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul
"featured_series_position": volume}, "featured_series_position": volume},
"", "",
) )
authors[book_id] = _author_set(book)
readers[book_id] = _count(book.get("users_count"))
if volume is None: if volume is None:
loose.append(book_id) loose.append(book_id)
else: else:
@@ -251,8 +312,16 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul
log.warning("hardcover series editions failed for %s: %s", log.warning("hardcover series editions failed for %s: %s",
series_id, exc) series_id, exc)
author = _main_author(authors)
def rank(book_id: int) -> tuple:
# an untagged edition is no evidence: bundles and Spanish rows have them too
edition = localized.get(book_id)
return (author in authors[book_id] if author else True,
bool(edition and edition.language), readers[book_id])
def pick(candidates: list[int]) -> MetadataResult: def pick(candidates: list[int]) -> MetadataResult:
best = next((i for i in candidates if i in localized), candidates[0]) best = max(candidates, key=rank)
return localized.get(best) or books[best] return localized.get(best) or books[best]
return ([pick(slots[volume]) for volume in sorted(slots)] return ([pick(slots[volume]) for volume in sorted(slots)]