diff --git a/README.md b/README.md index e5f28c2..32f189f 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. 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). +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. Mehrteiler in Unterordnern (`Teil A`/`Teil B`, `A - Titel`/`B - Titel`) zählen ebenfalls als ein Hörbuch. Liegen die Teile 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). Sind mehrere Einträge derselben Anfrage zugeordnet, fragt wordarr vor dem Import nach und fasst sie zusammen — sonst importiert nur der erste und der Rest scheitert. Fehlt einem bereits importierten Hörbuch später ein Teil, wählst du es im Zuordnungs-Dropdown unter *Bereits importiert — Teile anhängen* (↩︎); die neuen Dateien werden hinten angehängt und alle Tracks neu getaggt. ## Setup (Docker) diff --git a/static/app.js b/static/app.js index da169ba..fcfdf6e 100644 --- a/static/app.js +++ b/static/app.js @@ -621,6 +621,7 @@ $("#detail-form").addEventListener("submit", async (e) => { // ---- import ---- let scanItems = []; let missingReqs = []; +let importedReqs = []; let viewIdx = []; // indices into scanItems after filter/sort let importPage = 0; const IMPORT_PAGE_SIZE = 25; @@ -631,9 +632,10 @@ $("#scan-btn").addEventListener("click", async () => { $("#scan-info").innerHTML = ' Scanne…'; try { const split = $("#split-dirs").checked; - const [scan, reqs] = await Promise.all([ + const [scan, reqs, done] = await Promise.all([ api("/api/import/scan?split_dirs=" + split), api("/api/requests?status=missing"), + api("/api/requests?status=imported"), ]); scanItems = scan.items; // selection state lives here, not in the DOM, so it survives paging/filtering @@ -642,6 +644,7 @@ $("#scan-btn").addEventListener("click", async () => { item.checked = !!item.suggested_request_id; }); missingReqs = reqs; + importedReqs = done; importPage = 0; $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`; $("#import-toolbar").hidden = scanItems.length === 0; @@ -699,13 +702,18 @@ function renderImportTable() { tbody.innerHTML = pageIdx .map((i) => { const item = scanItems[i]; + const option = (r, suffix = "") => + ``; const opts = missingReqs .filter((r) => r.media_type === item.media_type) - .map( - (r) => - `` - ) + .map((r) => option(r)) + .join(""); + // already imported audiobooks can take further parts (a late CD, or one + // that failed while its siblings went through) + const appendOpts = importedReqs + .filter((r) => r.media_type === item.media_type && r.imported_path) + .map((r) => option(r, " ↩︎")) .join(""); return ` @@ -716,7 +724,9 @@ function renderImportTable() { ${esc(item.media_type)} - + ${item.parts ? `` : ""} @@ -1002,14 +1012,50 @@ async function pickQuickResult(r) { // ---- import execution (batched, with progress) ---- const IMPORT_BATCH_SIZE = 20; +// several entries on one request means one audiobook split across folders: +// importing them one by one only imports the first and fails the rest +function collapseSharedRequests(chosen) { + const byRequest = new Map(); + for (const item of chosen) { + const group = byRequest.get(item.request_id); + if (group) group.push(item); + else byRequest.set(item.request_id, [item]); + } + const shared = [...byRequest.values()].filter((g) => g.length > 1); + if (shared.length) { + const names = shared + .map((g) => `• ${g.map((it) => it.name).join(" + ")}`) + .join("\n"); + const ok = confirm( + `${shared.length} Anfrage(n) sind mehreren Einträgen zugeordnet:\n\n${names}\n\n` + + "Als je ein Hörbuch mit durchlaufenden Parts importieren?\n" + + "(Abbrechen: nichts wird importiert)" + ); + if (!ok) return null; + } + return [...byRequest.values()].map((group) => ({ + path: group.length > 1 ? sharedParent(group) : group[0].path, + is_dir: group.length > 1 ? true : group[0].is_dir, + files: group.flatMap((it) => it.files), + request_id: group[0].request_id, + append: importedReqs.some((r) => r.id === group[0].request_id), + })); +} + +// deepest folder that holds all of the group's entries +function sharedParent(group) { + const parts = group.map((it) => it.path.split("/")); + const first = parts[0]; + let i = 0; + while (i < first.length - 1 && parts.every((p) => p[i] === first[i])) i++; + return first.slice(0, i).join("/") || group[0].path; +} + $("#import-btn").addEventListener("click", async () => { - const items = scanItems - .filter((item) => item.checked && item.request_id) - .map((item) => ({ - path: item.path, is_dir: item.is_dir, files: item.files, - request_id: item.request_id, - })); - if (!items.length) { toast("Nichts ausgewählt", true); return; } + const chosen = scanItems.filter((item) => item.checked && item.request_id); + if (!chosen.length) { toast("Nichts ausgewählt", true); return; } + const items = collapseSharedRequests(chosen); + if (!items) return; const btn = $("#import-btn"); btn.disabled = true; const allResults = []; diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 98fc5df..7c5f01f 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -538,3 +538,112 @@ def test_merged_parts_clean_up_nested_source_folders(client): assert not (base / "A - Sphinx").exists() assert not (base / "B - Volk").exists() assert (base / "Cover" / "front.jpg").exists() + + +def test_letter_indexed_parts_are_one_audiobook(client): + """"Teil A/B/C" and "A - Titel/B - Titel" both describe one story.""" + schattenwelt = client.downloads / "175 Schattenwelt-3CD-DE-2015-VOiCE" + for part in ("Teil A", "Teil B", "Teil C"): + d = schattenwelt / part + d.mkdir(parents=True) + (d / "01.mp3").write_bytes(b"") + + toteninsel = client.downloads / "100 - Toteninsel" + for part in ("A - Das.Raetsel.der.Sphinx", "B - Das.vergessene.Volk", + "C - Der Fluch der Graeber"): + d = toteninsel / part + d.mkdir(parents=True) + (d / "01.mp3").write_bytes(b"") + (toteninsel / "Cover").mkdir() + (toteninsel / "Cover" / "f.jpg").write_bytes(b"") + + items = {i["name"]: i for i in client.get("/api/import/scan").json()["items"]} + assert sorted(items) == ["100 - Toteninsel", "175 Schattenwelt-3CD-DE-2015-VOiCE"] + for item in items.values(): + assert item["is_dir"] and len(item["files"]) == 3 + # parts stay in A, B, C order + assert [Path(f).parent.name[0] for f in item["files"]] in ( + ["T", "T", "T"], ["A", "B", "C"], + ) + order = [Path(f).parent.name for f in items["175 Schattenwelt-3CD-DE-2015-VOiCE"]["files"]] + assert order == ["Teil A", "Teil B", "Teil C"] + + +def test_letter_folders_that_are_not_parts_stay_separate(client): + """Two episodes that happen to start with a letter must not be merged.""" + base = client.downloads / "Sammlung" + for name in ("A - Erste Folge", "Zweite Folge"): + d = base / name + d.mkdir(parents=True) + (d / "01.mp3").write_bytes(b"") + (d / "02.mp3").write_bytes(b"") + names = sorted(i["name"] for i in client.get("/api/import/scan").json()["items"]) + assert names == ["A - Erste Folge", "Zweite Folge"] + + +def test_append_to_an_already_imported_audiobook(client): + """The Toteninsel case: part A got imported, B and C failed. They can be + added afterwards without moving anything back by hand.""" + root = client.tmp_path / "library" / "kids" + lib_id = client.post("/api/libraries", json={ + "name": "K", "media_type": "audiobook", "root_path": str(root), + "folder_template": "{Title}", "file_template": "{Title}", + }).json()["id"] + req_id = client.post("/api/requests", + json={"library_id": lib_id, "title": "Toteninsel"}).json()["id"] + + part_a = client.downloads / "A - Sphinx" + part_a.mkdir() + for i in (1, 2, 3): + (part_a / f"{i:02d}.mp3").write_bytes(b"") + client.post("/api/import", json={"items": [{ + "path": str(part_a), "is_dir": True, + "files": [str(f) for f in sorted(part_a.iterdir())], "request_id": req_id, + }]}) + imported = client.get("/api/requests", params={"status": "imported"}).json()[0] + assert len(list(Path(imported["imported_path"]).iterdir())) == 3 + + # B and C arrive later + later = [] + for part in ("B - Volk", "C - Graeber"): + d = client.downloads / part + d.mkdir() + for i in (1, 2): + f = d / f"{i:02d}.mp3" + f.write_bytes(b"") + later.append(str(f)) + res = client.post("/api/import", json={"items": [{ + "path": str(client.downloads / "B - Volk"), "is_dir": True, + "files": later, "request_id": req_id, "append": True, + }]}).json()["results"][0] + assert res["ok"] and res["appended"] + + files = sorted(p.name for p in Path(res["dest"]).iterdir()) + assert files == [f"Toteninsel - Part 0{i}.mp3" for i in range(1, 8)] + + +def test_append_without_the_flag_is_still_refused(client): + lib_id = client.post("/api/libraries", json={ + "name": "K", "media_type": "audiobook", "root_path": str(client.tmp_path / "k"), + }).json()["id"] + req_id = client.post("/api/requests", + json={"library_id": lib_id, "title": "X"}).json()["id"] + folder = client.downloads / "first" + folder.mkdir() + (folder / "a.mp3").write_bytes(b"") + (folder / "b.mp3").write_bytes(b"") + client.post("/api/import", json={"items": [{ + "path": str(folder), "is_dir": True, + "files": [str(folder / "a.mp3"), str(folder / "b.mp3")], "request_id": req_id, + }]}) + + second = client.downloads / "second" + second.mkdir() + (second / "c.mp3").write_bytes(b"") + res = client.post("/api/import", json={"items": [{ + "path": str(second), "is_dir": False, + "files": [str(second / "c.mp3")], "request_id": req_id, + }]}).json()["results"][0] + assert not res["ok"] + assert "bereits importiert" in res["error"] + assert (second / "c.mp3").exists() diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py index 33c324b..68a5435 100644 --- a/wordarr/api/imports.py +++ b/wordarr/api/imports.py @@ -30,6 +30,8 @@ class ImportItem(BaseModel): is_dir: bool files: list[str] request_id: int + # add to an already imported audiobook instead of importing a new one + append: bool = False class ImportIn(BaseModel): @@ -42,8 +44,28 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)): download_root = Path(config.DOWNLOAD_DIR).resolve() for item in data.items: req = session.get(BookRequest, item.request_id) - if not req or req.status != "missing": - results.append({"path": item.path, "ok": False, "error": "request not found or not missing"}) + if not req: + results.append({"path": item.path, "ok": False, + "error": f"Anfrage #{item.request_id} existiert nicht mehr"}) + continue + if item.append and req.status == "imported": + try: + dest = mover.append_to_import(item.files, req, req.library) + except Exception as exc: + results.append({"path": item.path, "ok": False, "error": str(exc)}) + continue + req.imported_path = dest + session.commit() + results.append({"path": item.path, "ok": True, "dest": dest, "appended": True}) + continue + if req.status != "missing": + # usually several entries pointing at the same request: the first one + # imported and flipped it, the rest land here + results.append({"path": item.path, "ok": False, "error": ( + f"Anfrage „{req.title}“ (#{req.id}) ist bereits importiert" + f"{' nach ' + req.imported_path if req.imported_path else ''}" + " — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen." + )}) continue # every source path must stay inside the download dir - merged items # carry files from several folders, so check them all diff --git a/wordarr/importer/mover.py b/wordarr/importer/mover.py index c553c59..4887004 100644 --- a/wordarr/importer/mover.py +++ b/wordarr/importer/mover.py @@ -6,6 +6,27 @@ from ..naming import render_template, sanitize from . import tagger +def _cleanup_sources(src_dir: Path, paths: list[Path]) -> None: + """Remove emptied source folders: the item itself plus every folder the files + came from, deepest first - "…/100 - Toteninsel/A - Sphinx/CD" contributes all + three. Anything still holding files (cover art, booklets) is left alone, and + the download dir itself is never removed.""" + download_root = Path(config.DOWNLOAD_DIR).resolve() + candidates = {src_dir} + for p in paths: + d = p.parent + while d == src_dir or src_dir in d.parents: + candidates.add(d) + if d == src_dir: + break + d = d.parent + for d in sorted(candidates, key=lambda p: len(p.parts), reverse=True): + 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) + + def import_item(item_path: str, files: list[str], is_dir: bool, request, library) -> str: """Move scanned files into the library, renamed per the library's templates. Returns the destination path (folder for multi-file audiobooks, else the file).""" @@ -22,26 +43,7 @@ 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 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) - download_root = Path(config.DOWNLOAD_DIR).resolve() - # collect every folder the files came from, up to (and including) the - # item itself - "…/100 - Toteninsel/A - Sphinx/CD" contributes all three - candidates = {src_dir} - for p in paths: - d = p.parent - while d == src_dir or src_dir in d.parents: - candidates.add(d) - if d == src_dir: - break - d = d.parent - for d in sorted(candidates, key=lambda p: len(p.parts), reverse=True): - 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) + _cleanup_sources(Path(item_path), paths) return str(folder) src = paths[0] @@ -52,3 +54,36 @@ def import_item(item_path: str, files: list[str], is_dir: bool, request, library if request.media_type == "audiobook": tagger.tag_audio(dest, request) return str(dest) + + +def append_to_import(files: list[str], request, library) -> str: + """Add more files to an audiobook that was already imported - a part that + arrived late, or one that failed on the first run. Numbering continues after + the files already there, and all tracks are re-tagged with the new total.""" + dest_dir = Path(request.imported_path or "") + if not dest_dir.is_dir(): + raise FileNotFoundError( + f"Zielordner existiert nicht (mehr): {request.imported_path}" + ) + existing = sorted( + f for f in dest_dir.iterdir() + if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS + ) + paths = [Path(f) for f in files] + total = len(existing) + len(paths) + width = max(2, len(str(total))) + base = render_template(library.file_template, request) + + for i, src in enumerate(paths, len(existing) + 1): + dest = dest_dir / sanitize(f"{base} - Part {i:0{width}d}{src.suffix.lower()}") + if dest.exists(): + raise FileExistsError(f"Destination already exists: {dest}") + shutil.move(str(src), dest) + if request.media_type == "audiobook": + tagger.tag_audio(dest, request, track=i, total=total) + if request.media_type == "audiobook": + for i, f in enumerate(existing, 1): + tagger.tag_audio(f, request, track=i, total=total) + for src_dir in {p.parent for p in paths}: + _cleanup_sources(src_dir, paths) + return str(dest_dir) diff --git a/wordarr/importer/scanner.py b/wordarr/importer/scanner.py index d8d3dac..2c4a4e0 100644 --- a/wordarr/importer/scanner.py +++ b/wordarr/importer/scanner.py @@ -3,13 +3,17 @@ from pathlib import Path from .. import config -# "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. The number is -# optional (single-disc rips just use "CD"). A bare number is deliberately not a -# disc: those folders are usually episodes of a series. +# "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Teil B" as folder name. The index is +# optional (single-disc rips just use "CD") and may be a letter. A bare number is +# deliberately not a disc: those folders are usually episodes of a series. DISC_DIR_RE = re.compile( - r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3})?$", + r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3}|[a-h])?$", re.IGNORECASE, ) +# "A - Das Raetsel der Sphinx", "B - Das vergessene Volk": parts of one story, +# told apart only by a leading letter. Only used when *every* audio subfolder +# follows the pattern. +LETTER_PART_RE = re.compile(r"^([a-h])\s*[-–—._:]\s*\S", re.IGNORECASE) def media_type_for(ext: str) -> str | None: @@ -28,13 +32,23 @@ def natural_key(name: str) -> list: return [int(p) if p.isdigit() else p.lower() for p in re.split(r"(\d+)", name)] +def _index(token: str) -> int: + return int(token) if token.isdigit() else ord(token.lower()) - ord("a") + 1 + + def disc_number(name: str) -> int | None: - """Disc index of a disc folder name, 0 for an unnumbered "CD". None if the - name is not a disc folder at all.""" + """Disc index of a disc folder name, 0 for an unnumbered "CD", 1-8 for a + letter ("Teil B"). None if the name is not a disc folder at all.""" m = DISC_DIR_RE.match(name.strip()) if not m: return None - return int(m.group(1)) if m.group(1) else 0 + return _index(m.group(1)) if m.group(1) else 0 + + +def letter_part(name: str) -> int | None: + """Index of an "A - Titel" style part folder, else None.""" + m = LETTER_PART_RE.match(name.strip()) + return _index(m.group(1)) if m else None def scan(root: Path, split_dirs: bool = False) -> list[dict]: @@ -67,14 +81,16 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]: """Audio files of d's disc subfolders ("CD", "CD1", "CD2", …), in disc order. Subfolders without audio (Cover, Scans, …) are ignored. None if d is not a disc-split folder.""" - discs = [] - for s in subdirs(d): - if not has_audio(s): - continue # artwork/booklet folder, not part of the audiobook - num = disc_number(s.name) - if num is None: - return None # a real subfolder -> not a disc split, walk normally - discs.append((num, s)) + audio_subs = [s for s in subdirs(d) if has_audio(s)] + # artwork/booklet folders are already filtered out above + discs = [(disc_number(s.name), s) for s in audio_subs] + if any(num is None for num, _ in discs): + # not disc folders - but "A - …", "B - …" are parts of one story too, + # as long as every single one of them follows that shape + letters = [(letter_part(s.name), s) for s in audio_subs] + if len(letters) < 2 or any(num is None for num, _ in letters): + return None # real subfolders -> walk normally + discs = letters files = [] for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))): files.extend(audio_files(s))