Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53927f7e11 | ||
|
|
adc708ae29 | ||
|
|
5f4b3cb564 | ||
|
|
b51d5754e0 | ||
|
|
91ad7ff98b | ||
|
|
c8d4d239ec | ||
|
|
13e2a0693e | ||
|
|
dc83071a0a | ||
|
|
e22eded2bf |
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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={
|
||||
|
||||
@@ -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")]
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user