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:
|
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
|
||||||
@@ -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
|
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
|
||||||
|
|
||||||
|
|||||||
+173
-27
@@ -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(...)}")
|
||||||
@@ -88,17 +92,26 @@ async function refreshMissingBadge() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- navigation ----
|
// ---- 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) =>
|
$$("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() {
|
||||||
@@ -360,7 +373,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;
|
||||||
@@ -512,7 +526,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);
|
||||||
}
|
}
|
||||||
|
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);
|
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();
|
||||||
}
|
}
|
||||||
@@ -529,7 +551,7 @@ function renderRequests(status) {
|
|||||||
}
|
}
|
||||||
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,6 +570,13 @@ 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;
|
||||||
@@ -826,6 +855,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 +874,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 +1046,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 +1059,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 +1214,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 +1239,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 +1495,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 +1505,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 +1548,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 +1576,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]) || {}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1637,3 +1782,4 @@ endLibraryEdit();
|
|||||||
loadLibraries();
|
loadLibraries();
|
||||||
refreshMissingBadge();
|
refreshMissingBadge();
|
||||||
showVersion();
|
showVersion();
|
||||||
|
showView();
|
||||||
@@ -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)",
|
||||||
@@ -146,6 +147,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 +257,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)",
|
||||||
@@ -367,6 +371,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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
+94
-1
@@ -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"]
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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")]
|
||||||
@@ -159,3 +159,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()
|
||||||
@@ -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):
|
||||||
@@ -101,9 +105,12 @@ 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
|
||||||
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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in new issue
Block a user