diff --git a/static/app.js b/static/app.js index a333679..0249997 100644 --- a/static/app.js +++ b/static/app.js @@ -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 ` @@ -844,6 +846,11 @@ function renderImportTable() { ${appendOpts ? `${appendOpts}` : ""} + ${override + ? `${esc( + t("import.folderSeries", override))}` + : ""} ${item.parts || item.maybe_separate ? `` : ""} @@ -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]) || {}), }, })); } diff --git a/static/i18n.js b/static/i18n.js index 9fee7f6..217fffc 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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", diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 2545a3a..68e4f4f 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -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={ diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py index 36962be..44e24f1 100644 --- a/wordarr/api/imports.py +++ b/wordarr/api/imports.py @@ -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" diff --git a/wordarr/importer/mover.py b/wordarr/importer/mover.py index 2da46dd..f53d95b 100644 --- a/wordarr/importer/mover.py +++ b/wordarr/importer/mover.py @@ -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)