Compare commits
2
Commits
91ad7ff98b
...
5f4b3cb564
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f4b3cb564 | ||
|
|
b51d5754e0 |
No files matched your search
@@ -827,6 +827,8 @@ function renderImportTable() {
|
||||
.filter((r) => r.media_type === item.media_type && r.imported_path)
|
||||
.map((r) => option(r, " ↩︎"))
|
||||
.join("");
|
||||
const override = seriesOverride(item);
|
||||
const req = override && missingReqs.find((r) => r.id === item.request_id);
|
||||
return `<tr class="${item.importState ? "row-" + item.importState : item.conflict ? "row-conflict" : ""}">
|
||||
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
||||
<td class="mono">
|
||||
@@ -844,6 +846,11 @@ function renderImportTable() {
|
||||
${appendOpts ? `<optgroup label="${esc(t("import.appendGroup"))}">${appendOpts}</optgroup>` : ""}
|
||||
</select>
|
||||
<button class="secondary" data-quick="${i}" title="${esc(t("import.quickTitle"))}">🔍</button>
|
||||
${override
|
||||
? `<span class="tag" title="${esc(t("import.folderSeriesTitle", {
|
||||
series: req.series || "–", volume: req.volume ?? "–" }))}">${esc(
|
||||
t("import.folderSeries", override))}</span>`
|
||||
: ""}
|
||||
${item.parts || item.maybe_separate
|
||||
? `<button class="secondary" data-split="${i}" title="${esc(t(item.parts ? "import.unmerge" : "import.splitDir"))}">✂️</button>`
|
||||
: ""}
|
||||
@@ -1513,6 +1520,25 @@ function groupByRequest(chosen) {
|
||||
return [...byRequest.values()];
|
||||
}
|
||||
|
||||
// "Autor - Georgia 01 - Titel": the release counts the series the way the
|
||||
// translation does, the metadata APIs often count the original's volumes
|
||||
function folderSeries(item) {
|
||||
const parent = (item.rel_dir || "").split("/").pop();
|
||||
for (const name of [item.name, parent]) {
|
||||
const { series, volume } = parseDownloadName(name || "");
|
||||
if (series && volume != null) return { series, volume };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// the folder's series when it differs from the chosen request's
|
||||
function seriesOverride(item) {
|
||||
const req = missingReqs.find((r) => r.id === item.request_id);
|
||||
const folder = folderSeries(item);
|
||||
if (!req || !folder) return null;
|
||||
return folder.series === req.series && folder.volume === req.volume ? null : folder;
|
||||
}
|
||||
|
||||
function buildImportGroups(groups) {
|
||||
return groups.map((group) => ({
|
||||
parts: group,
|
||||
@@ -1522,6 +1548,7 @@ function buildImportGroups(groups) {
|
||||
files: group.flatMap((it) => it.files),
|
||||
request_id: group[0].request_id,
|
||||
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
|
||||
...(seriesOverride(group[0]) || {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -146,6 +146,8 @@ const STRINGS = {
|
||||
"import.splitDir": "Ordner in einzelne Titel aufteilen",
|
||||
"import.parts": "{parts} Teile · {files} Dateien",
|
||||
"import.maybeSeparate": "{n} Titel?",
|
||||
"import.folderSeries": "→ {series} #{volume}",
|
||||
"import.folderSeriesTitle": "Reihe und Band aus dem Ordnernamen ersetzen beim Import die des Eintrags ({series} #{volume})",
|
||||
"import.maybeSeparateTitle": "Die {n} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen",
|
||||
"import.libraryCellTitle": "Ziel-Library dieser Anfrage ändern",
|
||||
"import.conflict": "⚠ Konflikt",
|
||||
@@ -367,6 +369,8 @@ const STRINGS = {
|
||||
"import.splitDir": "Split the folder into single titles",
|
||||
"import.parts": "{parts} parts · {files} files",
|
||||
"import.maybeSeparate": "{n} titles?",
|
||||
"import.folderSeries": "→ {series} #{volume}",
|
||||
"import.folderSeriesTitle": "The series and volume from the folder name replace the request's on import ({series} #{volume})",
|
||||
"import.maybeSeparateTitle": "The {n} files carry different titles and are each big enough for a whole book — split with ✂️",
|
||||
"import.libraryCellTitle": "Change this request's target library",
|
||||
"import.conflict": "⚠ Conflict",
|
||||
|
||||
+54
-1
@@ -25,6 +25,7 @@ GERMAN_EDITION = {
|
||||
"isbn_10": None,
|
||||
"release_date": "1957-03-01",
|
||||
"cached_image": "https://example.invalid/hobbit-de.jpg",
|
||||
"language": {"code2": "de"},
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +84,38 @@ def test_hardcover_drops_books_without_an_edition_in_that_language(monkeypatch):
|
||||
assert asyncio.run(hardcover.search("hobbit", "german")) == []
|
||||
|
||||
|
||||
def test_hardcover_keeps_a_book_whose_editions_carry_no_language(monkeypatch):
|
||||
"""German translations are often their own book row with untagged editions."""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
if "editions" in json.loads(request.content)["query"]:
|
||||
return httpx.Response(200, json={"data": {
|
||||
"editions": [{"book_id": 42, "title": "Der Hobbit", "language": None}],
|
||||
"other": [],
|
||||
}})
|
||||
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
|
||||
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
mock_client(monkeypatch, hardcover, handler)
|
||||
hit = asyncio.run(hardcover.search("hobbit", "german"))[0]
|
||||
assert (hit.title, hit.language) == ("Der Hobbit", "")
|
||||
|
||||
|
||||
def test_hardcover_drops_an_untagged_edition_of_a_foreign_book(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
if "editions" in json.loads(request.content)["query"]:
|
||||
return httpx.Response(200, json={"data": {
|
||||
"editions": [{"book_id": 42, "title": "The Hobbit", "language": None}],
|
||||
"other": [{"book_id": 42}],
|
||||
}})
|
||||
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
|
||||
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
mock_client(monkeypatch, hardcover, handler)
|
||||
assert asyncio.run(hardcover.search("hobbit", "german")) == []
|
||||
|
||||
|
||||
SERIES_ROWS = {
|
||||
"name": "Die drei Fragezeichen",
|
||||
"book_series": [
|
||||
@@ -129,7 +162,8 @@ def test_hardcover_series_sorts_by_volume_and_keeps_the_first_row(monkeypatch):
|
||||
def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch):
|
||||
calls = series_client(monkeypatch, [
|
||||
{"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251",
|
||||
"release_date": "1969-01-01", "cached_image": None},
|
||||
"release_date": "1969-01-01", "cached_image": None,
|
||||
"language": {"code2": "de"}},
|
||||
])
|
||||
books = asyncio.run(hardcover.series_books("10146", "german"))
|
||||
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
|
||||
@@ -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))
|
||||
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
|
||||
assert called == ["gb"] # no token, so Google Books stands in for Hardcover
|
||||
|
||||
|
||||
def test_hardcover_puts_hits_of_unknown_language_last(monkeypatch):
|
||||
untagged = {"document": {**HARDCOVER_HIT["document"], "id": "7", "title": "Bundle"}}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
if "editions" in json.loads(request.content)["query"]:
|
||||
return httpx.Response(200, json={"data": {"editions": [
|
||||
{"book_id": 7, "title": "Bundle", "language": None},
|
||||
GERMAN_EDITION,
|
||||
]}})
|
||||
return httpx.Response(200, json={"data": {"search": {"results": {
|
||||
"hits": [untagged, HARDCOVER_HIT]}}}})
|
||||
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
mock_client(monkeypatch, hardcover, handler)
|
||||
hits = asyncio.run(hardcover.search("hobbit", "german"))
|
||||
assert [h.title for h in hits] == ["Der Hobbit", "Bundle"]
|
||||
@@ -753,6 +753,8 @@ def test_relocate_after_fixing_the_series(client):
|
||||
# a cover dropped next to the audio must survive the move
|
||||
(Path(dest) / "cover.jpg").write_bytes(b"")
|
||||
|
||||
inode = Path(dest).stat().st_ino
|
||||
|
||||
client.put(f"/api/requests/{req_id}", json={
|
||||
"library_id": lib_id, "title": "Philosopher's Stone",
|
||||
"series": "Harry Potter", "volume": 1,
|
||||
@@ -761,6 +763,8 @@ def test_relocate_after_fixing_the_series(client):
|
||||
assert res["ok"] and res["moved"]
|
||||
new = root / "Harry Potter" / "Philosopher's Stone"
|
||||
assert Path(res["dest"]) == new
|
||||
# a renamed folder, not a new one: Audiobookshelf keeps the item and its cover
|
||||
assert new.stat().st_ino == inode
|
||||
assert sorted(p.name for p in new.iterdir()) == [
|
||||
"Philosopher's Stone - Part 01.mp3", "Philosopher's Stone - Part 02.mp3", "cover.jpg",
|
||||
]
|
||||
@@ -769,6 +773,45 @@ def test_relocate_after_fixing_the_series(client):
|
||||
"imported_path"] == str(new)
|
||||
|
||||
|
||||
|
||||
def test_the_folder_series_replaces_the_requests_on_import(client):
|
||||
"""Hardcover counts "Tote Augen" as Will Trent 3, the German release as Georgia 1."""
|
||||
root = client.tmp_path / "library" / "german"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "german", "media_type": "audiobook", "root_path": str(root),
|
||||
"folder_template": "{Series}/{Volume} - {Title}", "file_template": "{Title}",
|
||||
}).json()["id"]
|
||||
req_id = client.post("/api/requests", json={
|
||||
"library_id": lib_id, "title": "Tote Augen", "authors": "Karin Slaughter",
|
||||
"series": "Will Trent", "volume": 3,
|
||||
}).json()["id"]
|
||||
|
||||
def import_folder(name, **override):
|
||||
src = client.downloads / name
|
||||
src.mkdir()
|
||||
(src / "01.mp3").write_bytes(b"")
|
||||
return client.post("/api/import", json={"items": [{
|
||||
"path": str(src), "is_dir": True, "files": [str(src / "01.mp3")],
|
||||
"request_id": req_id, **override,
|
||||
}]}).json()["results"][0]
|
||||
|
||||
# a failed import must not leave the folder's series behind
|
||||
(root / "Georgia" / "01 - Tote Augen").mkdir(parents=True)
|
||||
(root / "Georgia" / "01 - Tote Augen" / "Tote Augen.mp3").write_bytes(b"")
|
||||
failed = import_folder("broken", series="Georgia", volume=1)
|
||||
assert not failed["ok"]
|
||||
req = client.get("/api/requests", params={"status": "missing"}).json()[0]
|
||||
assert (req["series"], req["volume"]) == ("Will Trent", 3)
|
||||
shutil.rmtree(root / "Georgia")
|
||||
|
||||
result = import_folder("Karin Slaughter - Georgia 01 - Tote Augen",
|
||||
series="Georgia", volume=1)
|
||||
assert result["ok"], result
|
||||
assert Path(result["dest"]).parent == root / "Georgia" / "01 - Tote Augen"
|
||||
req = client.get("/api/requests", params={"status": "imported"}).json()[0]
|
||||
assert (req["series"], req["volume"]) == ("Georgia", 1)
|
||||
|
||||
|
||||
def test_relocate_is_a_no_op_when_nothing_changed(client):
|
||||
root = client.tmp_path / "library" / "l"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
|
||||
@@ -47,6 +47,10 @@ class ImportItem(BaseModel):
|
||||
request_id: int
|
||||
# add to an already imported audiobook instead of importing a new one
|
||||
append: bool = False
|
||||
# series and volume the download folder names; they win over the request's,
|
||||
# since translations number their series differently from the metadata APIs
|
||||
series: str = ""
|
||||
volume: int | None = None
|
||||
|
||||
|
||||
class ImportIn(BaseModel):
|
||||
@@ -101,9 +105,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
||||
" — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen."
|
||||
)})
|
||||
continue
|
||||
if item.series.strip() and item.volume is not None:
|
||||
req.series, req.volume = item.series.strip(), item.volume
|
||||
try:
|
||||
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
|
||||
except Exception as exc:
|
||||
session.rollback() # the next commit must not keep the folder's series
|
||||
results.append({"path": shown, "ok": False, "error": str(exc)})
|
||||
continue
|
||||
req.status = "imported"
|
||||
|
||||
@@ -126,6 +126,18 @@ def relocate(request, library) -> str:
|
||||
_remove_if_empty(src.parent, root)
|
||||
return str(dest)
|
||||
|
||||
# one rename keeps the folder's inode, so Audiobookshelf sees a move and keeps
|
||||
# the item; filling a new folder file by file makes it add a new item halfway
|
||||
# and cache whatever cover it saw at that moment
|
||||
moved_from = None
|
||||
if not folder.exists():
|
||||
folder.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
src.rename(folder)
|
||||
moved_from, src = src, folder
|
||||
except OSError:
|
||||
pass # another filesystem, or a target inside the source: file by file
|
||||
|
||||
audio = sorted(
|
||||
(f for f in src.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS),
|
||||
@@ -173,6 +185,8 @@ def relocate(request, library) -> str:
|
||||
shutil.move(str(extra), target)
|
||||
if not same_folder:
|
||||
_remove_if_empty(src, root)
|
||||
if moved_from:
|
||||
_remove_if_empty(moved_from.parent, root)
|
||||
return str(folder)
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ query Search($q: String!, $per: Int!) {
|
||||
_EDITIONS = """
|
||||
query Editions($ids: [Int!]!, $lang: String!) {
|
||||
editions(
|
||||
where: {book_id: {_in: $ids}, language: {code2: {_eq: $lang}}}
|
||||
where: {
|
||||
book_id: {_in: $ids}
|
||||
_or: [{language: {code2: {_eq: $lang}}}, {language_id: {_is_null: true}}]
|
||||
}
|
||||
order_by: {users_count: desc_nulls_last}
|
||||
) {
|
||||
book_id
|
||||
@@ -47,6 +50,13 @@ query Editions($ids: [Int!]!, $lang: String!) {
|
||||
isbn_10
|
||||
release_date
|
||||
cached_image
|
||||
language { code2 }
|
||||
}
|
||||
other: editions(
|
||||
where: {book_id: {_in: $ids}, language: {code2: {_neq: $lang}}}
|
||||
distinct_on: book_id
|
||||
) {
|
||||
book_id
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -150,32 +160,49 @@ def _to_result(doc: dict, language: str) -> MetadataResult:
|
||||
|
||||
async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||
language: str) -> dict[int, MetadataResult]:
|
||||
"""The hits that have an edition in the wanted language, swapped in."""
|
||||
data = await _post(client, _EDITIONS,
|
||||
{"ids": list(results), "lang": LANGUAGES[language]})
|
||||
localized: dict[int, MetadataResult] = {}
|
||||
"""The hits that have an edition in the wanted language, swapped in.
|
||||
|
||||
An edition without a language counts too, unless the book has one in another
|
||||
language: translations are often a book row of their own whose editions nobody
|
||||
tagged ("Tote Augen"), while an English row always has tagged editions.
|
||||
"""
|
||||
code = LANGUAGES[language]
|
||||
data = await _post(client, _EDITIONS, {"ids": list(results), "lang": code})
|
||||
foreign = {ed.get("book_id") for ed in data.get("other") or []}
|
||||
known: dict[int, MetadataResult] = {}
|
||||
unknown: dict[int, MetadataResult] = {}
|
||||
for ed in data.get("editions") or []:
|
||||
book_id = ed.get("book_id")
|
||||
base = results.get(book_id)
|
||||
if base is None or book_id in localized:
|
||||
if base is None:
|
||||
continue
|
||||
if (ed.get("language") or {}).get("code2") == code:
|
||||
target, tag = known, language
|
||||
elif book_id not in foreign:
|
||||
target, tag = unknown, ""
|
||||
else:
|
||||
continue
|
||||
if book_id in target:
|
||||
continue # the first edition is the most read one
|
||||
release = str(ed.get("release_date") or "")
|
||||
localized[book_id] = base.model_copy(update={
|
||||
target[book_id] = base.model_copy(update={
|
||||
"title": ed.get("title") or base.title,
|
||||
"external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10"))
|
||||
or base.external_id),
|
||||
"year": int(release[:4]) if release[:4].isdigit() else base.year,
|
||||
"language": language,
|
||||
"language": tag,
|
||||
"cover_url": _image_url(ed.get("cached_image")) or base.cover_url,
|
||||
})
|
||||
return localized
|
||||
return {**unknown, **known}
|
||||
|
||||
|
||||
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||
language: str) -> list[MetadataResult]:
|
||||
"""Replace each hit with its edition in the wanted language, drop the rest."""
|
||||
"""Replace each hit with its edition in the wanted language, drop the rest.
|
||||
Hits whose language is only unknown go last."""
|
||||
localized = await _localized(client, results, language)
|
||||
return [localized[book_id] for book_id in results if book_id in localized]
|
||||
hits = [localized[book_id] for book_id in results if book_id in localized]
|
||||
return sorted(hits, key=lambda r: not r.language)
|
||||
|
||||
|
||||
async def search(query: str, language: str = "",
|
||||
@@ -288,8 +315,10 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul
|
||||
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,
|
||||
book_id in localized, readers[book_id])
|
||||
bool(edition and edition.language), readers[book_id])
|
||||
|
||||
def pick(candidates: list[int]) -> MetadataResult:
|
||||
best = max(candidates, key=rank)
|
||||
|
||||
Reference in new issue
Block a user