Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53927f7e11 | ||
|
|
adc708ae29 | ||
|
|
5f4b3cb564 | ||
|
|
b51d5754e0 |
No files matched your search
+66
-10
@@ -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() {
|
||||||
@@ -513,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);
|
||||||
}
|
}
|
||||||
currentRequests[status] = await api("/api/requests?" + params);
|
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||||||
|
if (!currentRequests[status].length) {
|
||||||
|
box.innerHTML = `<p class="muted"><span class="spinner"></span> ${t("common.loading")}</p>`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
currentRequests[status] = await api("/api/requests?" + params);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
renderRequests(status);
|
renderRequests(status);
|
||||||
if (status === "missing") refreshMissingBadge();
|
if (status === "missing") refreshMissingBadge();
|
||||||
}
|
}
|
||||||
@@ -530,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>
|
||||||
@@ -549,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;
|
||||||
@@ -827,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">
|
||||||
@@ -844,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>`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -1513,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,
|
||||||
@@ -1522,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]) || {}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1727,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; }
|
||||||
|
|||||||
+54
-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"}
|
||||||
@@ -280,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={
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,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
|
||||||
@@ -47,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@@ -150,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 = "",
|
||||||
@@ -288,8 +315,10 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul
|
|||||||
author = _main_author(authors)
|
author = _main_author(authors)
|
||||||
|
|
||||||
def rank(book_id: int) -> tuple:
|
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,
|
return (author in authors[book_id] if author else True,
|
||||||
book_id in localized, readers[book_id])
|
bool(edition and edition.language), readers[book_id])
|
||||||
|
|
||||||
def pick(candidates: list[int]) -> MetadataResult:
|
def pick(candidates: list[int]) -> MetadataResult:
|
||||||
best = max(candidates, key=rank)
|
best = max(candidates, key=rank)
|
||||||
|
|||||||
Reference in new issue
Block a user