Compare commits

16 changed files with 550 additions and 68 deletions

No files matched your search

+28 -4
View File
@@ -8,6 +8,11 @@ on:
jobs:
image:
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:
- name: Checkout
uses: actions/checkout@v4
@@ -20,18 +25,37 @@ jobs:
- name: Image bauen und pushen
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 }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER }}
ACTOR: ${{ gitea.actor }}
run: |
command -v docker >/dev/null || { apt-get update -qq && apt-get install -y -qq docker.io; }
sha=$(git rev-parse --short HEAD)
image=gitea.steppencloud.de/admin/wordarr
token=${REGISTRY_TOKEN:-$GITEA_TOKEN}
echo "$token" | docker login gitea.steppencloud.de -u "${{ gitea.actor }}" --password-stdin
# der sha landet als Build-Stempel neben der Version im UI
echo "$token" | docker login gitea.steppencloud.de -u "${REGISTRY_USER:-$ACTOR}" --password-stdin
docker build --build-arg WORDARR_BUILD="$sha" -t "$image:$sha" -t "$image:latest" .
docker push "$image:$sha"
docker push "$image:latest"
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
+3 -1
View File
@@ -42,7 +42,9 @@ between them and remembers the choice. Without one, the browser language decides
builds and pushes `gitea.steppencloud.de/admin/wordarr` as `:latest` and
`:<short sha>`, passing that sha as `WORDARR_BUILD`. It logs in with
`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
+173 -27
View File
@@ -39,12 +39,16 @@ async function createRequest(body) {
}
}
let toastTimer;
function toast(msg, isError = false) {
const t = $("#toast");
t.textContent = msg;
t.className = isError ? "error" : "";
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(...)}")
@@ -88,17 +92,26 @@ async function refreshMissingBadge() {
}
// ---- navigation ----
// the view lives in the hash so reload, back button and links keep it
const VIEWS = ["search", "missing", "imported", "import", "settings"];
function showView() {
const hash = location.hash.slice(1);
const name = VIEWS.includes(hash) ? hash : "search";
$$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
if (name === "missing" || name === "imported") loadRequests(name);
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
}
$$("nav button[data-view]").forEach((btn) =>
btn.addEventListener("click", () => {
$$("nav button[data-view]").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
$$("main > section").forEach((s) => (s.hidden = true));
$("#view-" + btn.dataset.view).hidden = false;
if (btn.dataset.view === "missing") loadRequests("missing");
if (btn.dataset.view === "imported") loadRequests("imported");
if (btn.dataset.view === "settings") loadLibraries();
// same hash fires no hashchange, but a click should still reload the list
if (location.hash === "#" + btn.dataset.view) showView();
else location.hash = btn.dataset.view;
})
);
window.addEventListener("hashchange", showView);
// ---- libraries ----
async function loadLibraries() {
@@ -360,7 +373,8 @@ async function openSeriesDialog(result, type = "audiobook", language = "") {
seriesEpisodes = await api(
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
`&title=${encodeURIComponent(result.series || result.title)}` +
`&media_type=${type}&language=${encodeURIComponent(language)}`
`&media_type=${type}&language=${encodeURIComponent(language)}` +
`&source=${encodeURIComponent(result.source || "")}`
);
} catch (err) {
$("#series-info").textContent = err.message;
@@ -512,7 +526,15 @@ async function loadRequests(status) {
if (t) params.set("media_type", t);
if (l) params.set("library_id", l);
}
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
if (!currentRequests[status].length) {
box.innerHTML = `<p class="muted"><span class="spinner"></span> ${t("common.loading")}</p>`;
}
try {
currentRequests[status] = await api("/api/requests?" + params);
} catch (err) {
toast(err.message, true);
}
renderRequests(status);
if (status === "missing") refreshMissingBadge();
}
@@ -529,7 +551,7 @@ function renderRequests(status) {
}
box.innerHTML = reqs
.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>'}
<div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong>
@@ -548,6 +570,13 @@ function renderRequests(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 n = box.querySelectorAll("[data-sel-req]:checked").length;
bulkBtn.hidden = n === 0;
@@ -826,6 +855,8 @@ function renderImportTable() {
.filter((r) => r.media_type === item.media_type && r.imported_path)
.map((r) => option(r, " ↩︎"))
.join("");
const override = seriesOverride(item);
const req = override && missingReqs.find((r) => r.id === item.request_id);
return `<tr class="${item.importState ? "row-" + item.importState : item.conflict ? "row-conflict" : ""}">
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
<td class="mono">
@@ -843,6 +874,11 @@ function renderImportTable() {
${appendOpts ? `<optgroup label="${esc(t("import.appendGroup"))}">${appendOpts}</optgroup>` : ""}
</select>
<button class="secondary" data-quick="${i}" title="${esc(t("import.quickTitle"))}">🔍</button>
${override
? `<span class="tag" title="${esc(t("import.folderSeriesTitle", {
series: req.series || "–", volume: req.volume ?? "–" }))}">${esc(
t("import.folderSeries", override))}</span>`
: ""}
${item.parts || item.maybe_separate
? `<button class="secondary" data-split="${i}" title="${esc(t(item.parts ? "import.unmerge" : "import.splitDir"))}">✂️</button>`
: ""}
@@ -1010,8 +1046,8 @@ $("#import-deselect-all").addEventListener("click", () => {
let namesTargets = [];
function nameToRequest(item) {
const { number, rest } = splitEpisodeNumber(item.name);
return { title: cleanFileName(rest) || item.name, volume: number };
const { title, volume } = parseDownloadName(item.name);
return { title, volume };
}
$("#import-from-names").addEventListener("click", () => {
@@ -1023,8 +1059,10 @@ $("#import-from-names").addEventListener("click", () => {
const type = namesTargets[0].media_type;
namesTargets = namesTargets.filter((it) => it.media_type === type);
$("#names-intro").textContent = t("names.intro", { n: namesTargets.length, type });
$("#names-series").value = lastSeries;
$("#names-authors").value = "";
// the folder names usually carry both, e.g. "Autor - Serie 03 - Titel"
const first = parseDownloadName(namesTargets[0].name);
$("#names-series").value = first.series || lastSeries;
$("#names-authors").value = first.authors;
$("#names-library").innerHTML = libOptions(type) || noLibraryOption();
renderNamesPreview();
$("#names-dialog").showModal();
@@ -1176,6 +1214,7 @@ function splitItem(i) {
// ---- quick request from a scanned file ----
let quickItemIndex = null;
let quickEpisode = null;
let quickSeries = ""; // series read from the folder name, if the hit has none
let quickPicked = null; // metadata hit the manual fields were filled from
let quickAutoSearch = false; // the search fired on open, not by the user
let lastSeries = "";
@@ -1200,29 +1239,115 @@ function splitEpisodeNumber(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
.replace(/[._\-\[\]()]+/g, " ")
.replace(/\b(mp3|m4b|flac|epub|pdf|cbz|cbr|retail|unabridged|kompl.*)\b/gi, " ")
.replace(LANG_TAG_RE, " ")
.replace(RELEASE_TAG_RE, " ")
.replace(/\s+/g, " ")
.trim();
}
function splitSeriesVolume(part) {
const m = SERIES_VOLUME_RE.exec(part.trim());
if (!m) return null;
const head = m[1].trim();
return { series: COUNTER_WORD_RE.test(head) ? "" : head, volume: parseInt(m[2], 10) };
}
function cleanFileName(name) {
return stripTags(name.replace(/[._\-\[\]()]+/g, " "));
}
// Download folders follow a few scene conventions: "Autor - Serie 03 - Titel",
// "Serie - 017 - Titel", "Autor - Titel (Serie 7)", "Autor_-_Titel-6CD-2013".
// The series number and the tags must stay out of the search query, but the
// leading segment must stay in it - for "Die drei ???" it is what Audible
// titles the books after.
function parseDownloadName(name) {
let series = "", volume = null, authors = "", lead = "";
let s = name.replace(/_/g, " ").replace(/(\p{L})\.(?=\p{L})/gu, "$1 ");
// "(Georgia 7)" names the series, "(ADAC Motorwelt Edition)" is noise
s = s.replace(/[[(]([^)\]]*)[)\]]/g, (_, inner) => {
const sv = splitSeriesVolume(stripTags(inner));
if (sv) {
series = sv.series || series;
volume = sv.volume;
}
return " ";
});
const parts = stripTags(s)
.split(/\s+[-–—]+\s+/)
.map((p) => p.replace(/^[\s.\-–—]+|[\s.\-–—]+$/g, ""))
.filter(Boolean);
if (parts.length >= 3) {
const sv = splitSeriesVolume(parts[1]);
if (sv) {
volume = sv.volume;
// a named middle part leaves the first one as the author, a bare number
// ("Serie - 017 - Titel") as the series
if (sv.series) {
series = sv.series;
authors = parts[0];
} else {
series = parts[0];
}
lead = parts.splice(0, 2)[0];
}
} else if (parts.length === 2) {
// "Die drei ??? 017 - Titel": series and number share the first part
const sv = splitSeriesVolume(parts[0]);
if (sv && sv.series) {
series = sv.series;
volume = sv.volume;
lead = sv.series;
parts.shift();
}
}
if (!lead && parts.length > 1 && PERSON_RE.test(parts[0])) {
authors = lead = parts.shift();
}
let rest = parts.join(" - ");
if (volume == null) {
const ep = splitEpisodeNumber(rest);
if (ep.number != null) {
volume = ep.number;
rest = ep.rest;
}
}
const title = cleanFileName(rest) || cleanFileName(name) || name;
return { title, volume, series, authors, query: cleanFileName(`${lead} ${rest}`) || title };
}
function openQuickDialog(i) {
quickItemIndex = i;
const item = scanItems[i];
// the number wrecks the Audible search, so it becomes the volume instead
const { number, rest } = splitEpisodeNumber(item.name);
quickEpisode = number;
const parsed = parseDownloadName(item.name);
quickEpisode = parsed.volume;
quickSeries = parsed.series;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
$("#quick-episode").textContent = number != null ? t("quick.episodeNote", { n: number }) : "";
$("#quick-q").value = cleanFileName(rest);
$("#quick-episode").textContent =
parsed.volume != null ? t("quick.episodeNote", { n: parsed.volume }) : "";
$("#quick-q").value = parsed.query;
quickPicked = null;
$("#quick-req-filter").value = "";
$("#quick-m-title").value = cleanFileName(rest);
$("#quick-m-authors").value = "";
$("#quick-m-volume").value = number ?? "";
$("#quick-m-series").value = lastSeries;
$("#quick-m-title").value = parsed.title;
$("#quick-m-authors").value = parsed.authors;
$("#quick-m-volume").value = parsed.volume ?? "";
$("#quick-m-series").value = parsed.series || lastSeries;
$("#quick-based-on").hidden = true;
$("#quick-manual-summary").textContent = t("quick.manualSummary");
$("#quick-manual").open = false;
@@ -1370,7 +1495,7 @@ function pickQuickResult(r) {
return requestAndConnect({
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id, volume: r.volume ?? quickEpisode,
year: r.year, series: r.series, cover_url: r.cover_url,
year: r.year, series: r.series || quickSeries, cover_url: r.cover_url,
});
}
@@ -1380,7 +1505,7 @@ function editBeforeRequest(r) {
quickPicked = r;
$("#quick-m-title").value = r.title || "";
$("#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-based-on").textContent = t("quick.basedOn", { title: r.title });
$("#quick-based-on").hidden = false;
@@ -1423,6 +1548,25 @@ function groupByRequest(chosen) {
return [...byRequest.values()];
}
// "Autor - Georgia 01 - Titel": the release counts the series the way the
// translation does, the metadata APIs often count the original's volumes
function folderSeries(item) {
const parent = (item.rel_dir || "").split("/").pop();
for (const name of [item.name, parent]) {
const { series, volume } = parseDownloadName(name || "");
if (series && volume != null) return { series, volume };
}
return null;
}
// the folder's series when it differs from the chosen request's
function seriesOverride(item) {
const req = missingReqs.find((r) => r.id === item.request_id);
const folder = folderSeries(item);
if (!req || !folder) return null;
return folder.series === req.series && folder.volume === req.volume ? null : folder;
}
function buildImportGroups(groups) {
return groups.map((group) => ({
parts: group,
@@ -1432,6 +1576,7 @@ function buildImportGroups(groups) {
files: group.flatMap((it) => it.files),
request_id: group[0].request_id,
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
...(seriesOverride(group[0]) || {}),
},
}));
}
@@ -1637,3 +1782,4 @@ endLibraryEdit();
loadLibraries();
refreshMissingBadge();
showVersion();
showView();
+6
View File
@@ -33,6 +33,7 @@ const STRINGS = {
"common.status": "Status",
"common.noLibrary": "— keine Library —",
"common.nothingHere": "Nichts hier.",
"common.loading": "Wird geladen…",
"common.optionalForAll": "optional, für alle",
"common.optional": "optional",
"common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)",
@@ -146,6 +147,8 @@ const STRINGS = {
"import.splitDir": "Ordner in einzelne Titel aufteilen",
"import.parts": "{parts} Teile · {files} Dateien",
"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.libraryCellTitle": "Ziel-Library dieser Anfrage ändern",
"import.conflict": "⚠ Konflikt",
@@ -254,6 +257,7 @@ const STRINGS = {
"common.status": "Status",
"common.noLibrary": "— no library —",
"common.nothingHere": "Nothing here.",
"common.loading": "Loading…",
"common.optionalForAll": "optional, for all",
"common.optional": "optional",
"common.needLibraryFirst": "Create a library for this type first (Libraries tab)",
@@ -367,6 +371,8 @@ const STRINGS = {
"import.splitDir": "Split the folder into single titles",
"import.parts": "{parts} parts · {files} files",
"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.libraryCellTitle": "Change this request's target library",
"import.conflict": "⚠ Conflict",
+1
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: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; }
.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; }
+94 -1
View File
@@ -25,6 +25,7 @@ GERMAN_EDITION = {
"isbn_10": None,
"release_date": "1957-03-01",
"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")) == []
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 = {
"name": "Die drei Fragezeichen",
"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):
calls = series_client(monkeypatch, [
{"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"))
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"]
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 handler(request: httpx.Request) -> httpx.Response:
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))
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
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"]
+43
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
(Path(dest) / "cover.jpg").write_bytes(b"")
inode = Path(dest).stat().st_ino
client.put(f"/api/requests/{req_id}", json={
"library_id": lib_id, "title": "Philosopher's Stone",
"series": "Harry Potter", "volume": 1,
@@ -761,6 +763,8 @@ def test_relocate_after_fixing_the_series(client):
assert res["ok"] and res["moved"]
new = root / "Harry Potter" / "Philosopher's Stone"
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()) == [
"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)
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):
root = client.tmp_path / "library" / "l"
lib_id = client.post("/api/libraries", json={
+39
View File
@@ -82,3 +82,42 @@ def test_a_broken_fallback_never_breaks_the_search(monkeypatch):
monkeypatch.setattr(metadata.musicbrainz, "search", boom)
results = asyncio.run(metadata.audiobook_search("Toteninsel"))
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")]
+24
View File
@@ -159,3 +159,27 @@ def test_the_header_shows_the_running_version(page):
stamp = page.locator("#app-version").text_content()
assert stamp.startswith("v")
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()
+7
View File
@@ -47,6 +47,10 @@ class ImportItem(BaseModel):
request_id: int
# add to an already imported audiobook instead of importing a new one
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):
@@ -101,9 +105,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
" — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen."
)})
continue
if item.series.strip() and item.volume is not None:
req.series, req.volume = item.series.strip(), item.volume
try:
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc:
session.rollback() # the next commit must not keep the folder's series
results.append({"path": shown, "ok": False, "error": str(exc)})
continue
req.status = "imported"
+2 -3
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -21,8 +21,7 @@ class LibraryIn(BaseModel):
class LibraryOut(LibraryIn):
id: int
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
def _validate(data: LibraryIn):
+2 -3
View File
@@ -1,7 +1,7 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -34,8 +34,7 @@ class RequestOut(RequestIn):
imported_path: str
library_name: str = ""
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
def _out(r: BookRequest) -> RequestOut:
+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])
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
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)
if not lookup:
raise HTTPException(400, f"no series lookup for media_type: {media_type}")
if not series_id:
raise HTTPException(400, "series_id required")
try:
return await lookup(series_id, title, language)
return await lookup(series_id, title, language, source)
except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}")
+14
View File
@@ -126,6 +126,18 @@ def relocate(request, library) -> str:
_remove_if_empty(src.parent, root)
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(
(f for f in src.iterdir()
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)
if not same_folder:
_remove_if_empty(src, root)
if moved_from:
_remove_if_empty(moved_from.parent, root)
return str(folder)
+27 -12
View File
@@ -58,38 +58,53 @@ async def ebook_search(query: str, language: str = "",
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 = "",
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
detected reliably - measuring it showed the fallback would never fire. Both
are therefore asked in parallel and the MusicBrainz hits are appended, marked
with their source. Callers that search automatically (the import dialog when
it opens) pass fallback=False to spare the rate limited service.
detected reliably - measuring it showed the fallback would never fire. All
are therefore asked in parallel and the extra hits are appended, marked with
their source. Hardcover is there for its series: Audible leaves most volumes
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:
return await audible.search(query, language)
hits, extra = await asyncio.gather(
hits, *extras = await asyncio.gather(
audible.search(query, language),
musicbrainz.search(query, language),
hardcover.search(query, language),
return_exceptions=True,
)
if isinstance(hits, BaseException):
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}
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)
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)
+83 -14
View File
@@ -13,6 +13,7 @@ Series are one query - unlike Audible, Hardcover knows them.
import logging
import os
from collections import Counter
import httpx
@@ -37,7 +38,10 @@ query Search($q: String!, $per: Int!) {
_EDITIONS = """
query Editions($ids: [Int!]!, $lang: String!) {
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}
) {
book_id
@@ -46,6 +50,13 @@ query Editions($ids: [Int!]!, $lang: String!) {
isbn_10
release_date
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
title
release_year
users_count
cached_image
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],
language: str) -> dict[int, MetadataResult]:
"""The hits that have an edition in the wanted language, swapped in."""
data = await _post(client, _EDITIONS,
{"ids": list(results), "lang": LANGUAGES[language]})
localized: dict[int, MetadataResult] = {}
"""The hits that have an edition in the wanted language, swapped in.
An edition without a language counts too, unless the book has one in another
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 []:
book_id = ed.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
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,
"external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10"))
or base.external_id),
"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,
})
return localized
return {**unknown, **known}
async def _localize(client: httpx.AsyncClient, results: dict[int, 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)
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 = "",
@@ -204,10 +233,38 @@ async def search(query: str, language: str = "",
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]:
"""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
language wins the slot, else the first. Unnumbered books go last."""
rows - other editions of the story, and now and then a foreign book someone
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:
return []
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
loose: list[int] = []
books: dict[int, MetadataResult] = {}
authors: dict[int, set[str]] = {}
readers: dict[int, int] = {}
for entry in row.get("book_series") or []:
book = entry.get("book") or {}
try:
@@ -238,6 +297,8 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul
"featured_series_position": volume},
"",
)
authors[book_id] = _author_set(book)
readers[book_id] = _count(book.get("users_count"))
if volume is None:
loose.append(book_id)
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",
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:
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 ([pick(slots[volume]) for volume in sorted(slots)]