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 ? `` : ""}
+ ${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)
|