From 36263d66fbde2cd6a655f2dd92afd83b62583ca4 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Fri, 28 Aug 2026 20:33:26 +0200 Subject: [PATCH] add(import): merge multi-part entries into one audiobook --- README.md | 2 +- static/app.js | 61 ++++++++++++++++++++++++++ static/index.html | 3 ++ static/style.css | 1 + tests/test_import_flow.py | 90 +++++++++++++++++++++++++++++++++++++++ wordarr/api/imports.py | 6 ++- wordarr/importer/mover.py | 17 ++++++-- 7 files changed, 173 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b87b873..e5f28c2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas** 1. **Anfragen**: Im Web-UI per Titel/Autor/ISBN suchen (Ebooks: Open Library · Audiobooks: Audible · Manga: AniList) oder manuell anlegen. Beim Request wählst du die **Ziel-Library** (z.B. deine 8 Bookorbit-Libraries oder die 3 Audiobookshelf-Libraries english/adult/kids). Der Titel erscheint als **Missing**. Gehört ein Audiobook-Treffer zu einer Serie, legt **„Ganze Serie…"** alle Folgen auf einmal an (Folgennummer + echter Titel von Audible, optional auf einen Folgenbereich eingegrenzt). Audible hat keine Serien-Abfrage — wordarr sammelt die Folgen über mehrere Suchläufe ein; sollte eine fehlen, zeigt der Dialog die Lücke an. 2. **Download-Ordner**: wordarr überwacht keinen Downloader aktiv — du legst Dateien selbst in den Download-Ordner (`/mnt/downloads`). -3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst. Über 🔍 lässt sich zu einem Ordner direkt eine Anfrage suchen — eine führende Folgennummer (`017 - Titel`) wird dabei aus der Suchanfrage genommen, weil Audible sonst schlechter trifft, und stattdessen als Band/Folge übernommen. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert. +3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst. Über 🔍 lässt sich zu einem Ordner direkt eine Anfrage suchen — eine führende Folgennummer (`017 - Titel`) wird dabei aus der Suchanfrage genommen, weil Audible sonst schlechter trifft, und stattdessen als Band/Folge übernommen. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert. Liegen die Teile eines Mehrteilers dagegen **nebeneinander** (`100 - Toteninsel Teil 1`, `… Teil 2`, …), markierst du sie und klickst *Ausgewählte zusammenfassen* — sie werden als ein Hörbuch mit durchlaufenden Parts importiert (✂️ löst das wieder auf). ## Setup (Docker) diff --git a/static/app.js b/static/app.js index 83414a1..de3d2d3 100644 --- a/static/app.js +++ b/static/app.js @@ -711,12 +711,14 @@ function renderImportTable() { ${esc(item.name)}${item.is_dir ? " 📁" : ""} + ${item.parts ? `${item.parts.length} Teile · ${item.files.length} Dateien` : ""} ${item.rel_dir ? `
${esc(item.rel_dir)}/
` : ""} ${esc(item.media_type)} + ${item.parts ? `` : ""} ${scoreCell(item)} `; @@ -728,6 +730,9 @@ function renderImportTable() { tbody.querySelectorAll("[data-quick]").forEach((b) => b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick))) ); + tbody.querySelectorAll("[data-split]").forEach((b) => + b.addEventListener("click", () => splitMergedItem(parseInt(b.dataset.split))) + ); tbody.querySelectorAll("[data-check]").forEach((c) => c.addEventListener("change", () => { scanItems[c.dataset.check].checked = c.checked; @@ -783,6 +788,62 @@ $("#import-deselect-all").addEventListener("click", () => { renderImportTable(); }); +// ---- multi-part episodes: import several entries as one audiobook ---- +// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title +function commonPrefix(names) { + let prefix = names[0]; + for (const n of names.slice(1)) { + let i = 0; + while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++; + prefix = prefix.slice(0, i); + } + // drop a dangling part marker: "100 - Toteninsel Teil " -> "100 - Toteninsel" + return prefix.replace(/[\s._\-–—:]*(?:teil|part|cd|disc|folge)?[\s._\-–—:]*$/i, "").trim(); +} + +$("#import-merge").addEventListener("click", () => { + const chosen = scanItems.filter((it) => it.checked); + if (chosen.length < 2) { + toast("Mindestens zwei Einträge auswählen", true); + return; + } + if (new Set(chosen.map((it) => it.media_type)).size > 1) { + toast("Nur Einträge desselben Typs lassen sich zusammenfassen", true); + return; + } + const parents = new Set(chosen.map((it) => it.rel_dir)); + if (parents.size > 1) { + toast("Die Einträge liegen in verschiedenen Ordnern", true); + return; + } + const name = commonPrefix(chosen.map((it) => it.name)) || chosen[0].name; + const merged = { + path: chosen[0].path.slice(0, chosen[0].path.length - chosen[0].name.length - 1) || chosen[0].path, + name, + rel_dir: chosen[0].rel_dir, + media_type: chosen[0].media_type, + is_dir: true, + files: chosen.flatMap((it) => it.files), + checked: true, + request_id: chosen.find((it) => it.request_id)?.request_id ?? null, + suggested_request_id: chosen.find((it) => it.suggested_request_id)?.suggested_request_id ?? null, + score: Math.max(...chosen.map((it) => it.score ?? 0)), + parts: chosen, + }; + const first = scanItems.indexOf(chosen[0]); + scanItems = scanItems.filter((it) => !chosen.includes(it)); + scanItems.splice(first, 0, merged); + toast(`${chosen.length} Einträge als „${name}" zusammengefasst (${merged.files.length} Dateien)`); + renderImportTable(); +}); + +function splitMergedItem(i) { + const item = scanItems[i]; + if (!item.parts) return; + scanItems.splice(i, 1, ...item.parts); + renderImportTable(); +} + // ---- quick request from a scanned file ---- let quickItemIndex = null; let quickEpisode = null; diff --git a/static/index.html b/static/index.html index 6e7c3b7..bbf9f01 100644 --- a/static/index.html +++ b/static/index.html @@ -162,6 +162,9 @@ +
diff --git a/static/style.css b/static/style.css index b009bce..e122b34 100644 --- a/static/style.css +++ b/static/style.css @@ -54,6 +54,7 @@ button:disabled { opacity: 0.6; cursor: default; } .muted { color: var(--muted); font-size: 0.88rem; } .mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; } .subpath { color: var(--muted); font-size: 0.75rem; font-family: ui-monospace, monospace; } +.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; } /* view modes (Missing/Importiert) */ .view-toggle { display: flex; gap: 0; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; margin-left: auto; } diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 2628126..b8732d7 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -414,3 +414,93 @@ def test_bulk_items_series_then_scan_matches_by_episode_number(client): n = int(item["name"][:3]) assert item["suggested_request_id"] == by_volume[n], item assert item["score"] >= 90 + + +def test_merged_multipart_import(client): + """A multi-part episode ("Teil 1/2/3") imported as one audiobook: the client + merges the scanned entries and posts their files as a single item.""" + root = client.tmp_path / "library" / "ddf" + lib_id = client.post("/api/libraries", json={ + "name": "DDF", "media_type": "audiobook", "root_path": str(root), + "folder_template": "{Series}/{Title}", "file_template": "{Title}", + }).json()["id"] + req_id = client.post("/api/requests", json={ + "library_id": lib_id, "title": "Die drei ??? und die Toteninsel", + "series": "Die drei ???", "volume": 100, + }).json()["id"] + + base = client.downloads / "Folgen" / "100 - Toteninsel" + for part in ("Teil 1", "Teil 2", "Teil 3"): + d = base / part + d.mkdir(parents=True) + for i in (1, 2): + (d / f"{i:02d}.mp3").write_bytes(b"") + (base / "Cover").mkdir() + (base / "Cover" / "front.jpg").write_bytes(b"") + + # "Teil N" folders are disc folders, so the scan already yields one entry + items = client.get("/api/import/scan").json()["items"] + assert len(items) == 1 and len(items[0]["files"]) == 6 + + # a split scan yields the parts separately - that is what the merge button + # in the UI stitches back together + parts = client.get("/api/import/scan", params={"split_dirs": True}).json()["items"] + assert len(parts) == 6 + merged_files = [f for p in parts for f in p["files"]] + + res = client.post("/api/import", json={"items": [{ + "path": str(base), "is_dir": True, "files": merged_files, "request_id": req_id, + }]}).json()["results"][0] + assert res["ok"], res + + dest = sorted(p.name for p in Path(res["dest"]).iterdir()) + assert dest == [f"Die drei ??? und die Toteninsel - Part 0{i}.mp3" for i in range(1, 7)] + # emptied part folders are gone, the cover folder survives + assert not (base / "Teil 1").exists() + assert (base / "Cover" / "front.jpg").exists() + + +def test_import_rejects_files_outside_download_dir(client): + lib_id = client.post("/api/libraries", json={ + "name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"), + }).json()["id"] + req_id = client.post("/api/requests", + json={"library_id": lib_id, "title": "X"}).json()["id"] + outside = client.tmp_path / "elsewhere" + outside.mkdir() + (outside / "a.mp3").write_bytes(b"") + folder = client.downloads / "ok" + folder.mkdir() + (folder / "b.mp3").write_bytes(b"") + + res = client.post("/api/import", json={"items": [{ + "path": str(folder), "is_dir": True, + "files": [str(folder / "b.mp3"), str(outside / "a.mp3")], "request_id": req_id, + }]}).json()["results"][0] + assert not res["ok"] and "outside" in res["error"] + assert (outside / "a.mp3").exists() + + +def test_merge_at_download_root_keeps_the_download_dir(client): + """Merging entries that sit directly in the download dir makes the item path + the download dir itself - which must never be removed.""" + lib_id = client.post("/api/libraries", json={ + "name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"), + }).json()["id"] + req_id = client.post("/api/requests", + json={"library_id": lib_id, "title": "Toteninsel"}).json()["id"] + files = [] + for part in ("Teil 1", "Teil 2"): + d = client.downloads / part + d.mkdir() + (d / "a.mp3").write_bytes(b"") + (d / "b.mp3").write_bytes(b"") + files += [str(d / "a.mp3"), str(d / "b.mp3")] + + res = client.post("/api/import", json={"items": [{ + "path": str(client.downloads), "is_dir": True, "files": files, + "request_id": req_id, + }]}).json()["results"][0] + assert res["ok"], res + assert client.downloads.is_dir() + assert not (client.downloads / "Teil 1").exists() diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py index eb54ead..33c324b 100644 --- a/wordarr/api/imports.py +++ b/wordarr/api/imports.py @@ -45,8 +45,10 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)): if not req or req.status != "missing": results.append({"path": item.path, "ok": False, "error": "request not found or not missing"}) continue - src = Path(item.path).resolve() - if download_root not in src.parents and src != download_root: + # every source path must stay inside the download dir - merged items + # carry files from several folders, so check them all + paths = [Path(p).resolve() for p in [item.path, *item.files]] + if any(download_root not in p.parents and p != download_root for p in paths): results.append({"path": item.path, "ok": False, "error": "path outside download dir"}) continue try: diff --git a/wordarr/importer/mover.py b/wordarr/importer/mover.py index 854c87f..1734c90 100644 --- a/wordarr/importer/mover.py +++ b/wordarr/importer/mover.py @@ -1,6 +1,7 @@ import shutil from pathlib import Path +from .. import config from ..naming import render_template, sanitize from . import tagger @@ -21,11 +22,19 @@ def import_item(item_path: str, files: list[str], is_dir: bool, request, library shutil.move(str(src), dest) if request.media_type == "audiobook": tagger.tag_audio(dest, request, track=i, total=len(paths)) - # remove the source dir if nothing meaningful is left + # remove emptied source folders: the item itself plus the disc/part + # subfolders the files came from, deepest first. Anything still holding + # files (cover art, booklets) is left alone. src_dir = Path(item_path) - leftovers = [p for p in src_dir.rglob("*") if p.is_file()] - if not leftovers: - shutil.rmtree(src_dir, ignore_errors=True) + download_root = Path(config.DOWNLOAD_DIR).resolve() + candidates = {src_dir} | {p.parent for p in paths} + for d in sorted(candidates, key=lambda p: len(p.parts), reverse=True): + if d != src_dir and src_dir not in d.parents: + continue # never touch anything outside the item + if d.resolve() == download_root: + continue # merged items can point at the download dir itself + if d.is_dir() and not any(f.is_file() for f in d.rglob("*")): + shutil.rmtree(d, ignore_errors=True) return str(folder) src = paths[0]