Compare commits

11 changed files with 260 additions and 29 deletions

No files matched your search

+66 -10
View File
@@ -39,12 +39,16 @@ async function createRequest(body) {
} }
} }
let toastTimer;
function toast(msg, isError = false) { function toast(msg, isError = false) {
const t = $("#toast"); const t = $("#toast");
t.textContent = msg; t.textContent = msg;
t.className = isError ? "error" : ""; t.className = isError ? "error" : "";
t.hidden = false; t.hidden = false;
setTimeout(() => (t.hidden = true), 4000); // an older toast's timer would otherwise hide this one early
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (t.hidden = true), 4000);
} }
// quotes included: the result also lands inside attributes (value="${esc(...)}") // quotes included: the result also lands inside attributes (value="${esc(...)}")
@@ -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();
+6
View File
@@ -33,6 +33,7 @@ const STRINGS = {
"common.status": "Status", "common.status": "Status",
"common.noLibrary": "— keine Library —", "common.noLibrary": "— keine Library —",
"common.nothingHere": "Nichts hier.", "common.nothingHere": "Nichts hier.",
"common.loading": "Wird geladen…",
"common.optionalForAll": "optional, für alle", "common.optionalForAll": "optional, für alle",
"common.optional": "optional", "common.optional": "optional",
"common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)", "common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)",
@@ -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",
+1
View File
@@ -48,6 +48,7 @@ button:disabled { opacity: 0.6; cursor: default; }
} }
.card.clickable { cursor: pointer; transition: border-color 0.1s, background 0.1s; } .card.clickable { 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
View File
@@ -25,6 +25,7 @@ GERMAN_EDITION = {
"isbn_10": None, "isbn_10": None,
"release_date": "1957-03-01", "release_date": "1957-03-01",
"cached_image": "https://example.invalid/hobbit-de.jpg", "cached_image": "https://example.invalid/hobbit-de.jpg",
"language": {"code2": "de"},
} }
@@ -83,6 +84,38 @@ def test_hardcover_drops_books_without_an_edition_in_that_language(monkeypatch):
assert asyncio.run(hardcover.search("hobbit", "german")) == [] assert asyncio.run(hardcover.search("hobbit", "german")) == []
def test_hardcover_keeps_a_book_whose_editions_carry_no_language(monkeypatch):
"""German translations are often their own book row with untagged editions."""
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {
"editions": [{"book_id": 42, "title": "Der Hobbit", "language": None}],
"other": [],
}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
hit = asyncio.run(hardcover.search("hobbit", "german"))[0]
assert (hit.title, hit.language) == ("Der Hobbit", "")
def test_hardcover_drops_an_untagged_edition_of_a_foreign_book(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {
"editions": [{"book_id": 42, "title": "The Hobbit", "language": None}],
"other": [{"book_id": 42}],
}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
assert asyncio.run(hardcover.search("hobbit", "german")) == []
SERIES_ROWS = { SERIES_ROWS = {
"name": "Die drei Fragezeichen", "name": "Die drei Fragezeichen",
"book_series": [ "book_series": [
@@ -129,7 +162,8 @@ def test_hardcover_series_sorts_by_volume_and_keeps_the_first_row(monkeypatch):
def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch): def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch):
calls = series_client(monkeypatch, [ calls = series_client(monkeypatch, [
{"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251", {"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251",
"release_date": "1969-01-01", "cached_image": None}, "release_date": "1969-01-01", "cached_image": None,
"language": {"code2": "de"}},
]) ])
books = asyncio.run(hardcover.series_books("10146", "german")) books = asyncio.run(hardcover.series_books("10146", "german"))
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"} assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
@@ -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"]
+43
View File
@@ -753,6 +753,8 @@ def test_relocate_after_fixing_the_series(client):
# a cover dropped next to the audio must survive the move # 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={
+24
View File
@@ -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()
+7
View File
@@ -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"
+2 -3
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,8 +21,7 @@ class LibraryIn(BaseModel):
class LibraryOut(LibraryIn): class LibraryOut(LibraryIn):
id: int id: int
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
def _validate(data: LibraryIn): def _validate(data: LibraryIn):
+2 -3
View File
@@ -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:
+14
View File
@@ -126,6 +126,18 @@ def relocate(request, library) -> str:
_remove_if_empty(src.parent, root) _remove_if_empty(src.parent, root)
return str(dest) return str(dest)
# one rename keeps the folder's inode, so Audiobookshelf sees a move and keeps
# the item; filling a new folder file by file makes it add a new item halfway
# and cache whatever cover it saw at that moment
moved_from = None
if not folder.exists():
folder.parent.mkdir(parents=True, exist_ok=True)
try:
src.rename(folder)
moved_from, src = src, folder
except OSError:
pass # another filesystem, or a target inside the source: file by file
audio = sorted( audio = sorted(
(f for f in src.iterdir() (f for f in src.iterdir()
if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS), if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS),
@@ -173,6 +185,8 @@ def relocate(request, library) -> str:
shutil.move(str(extra), target) shutil.move(str(extra), target)
if not same_folder: if not same_folder:
_remove_if_empty(src, root) _remove_if_empty(src, root)
if moved_from:
_remove_if_empty(moved_from.parent, root)
return str(folder) return str(folder)
+41 -12
View File
@@ -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)