From b8f042025d4eb30f776d22b1e65e4aa48747a838 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Fri, 28 Aug 2026 19:21:24 +0200 Subject: [PATCH] fix(import): treat cd1/cd2 folders as one audiobook --- README.md | 2 +- tests/test_import_flow.py | 51 ++++++++++++++++++++++- tests/test_matcher.py | 23 +++++++++++ wordarr/importer/matcher.py | 15 ++++++- wordarr/importer/scanner.py | 80 ++++++++++++++++++++++++++++++------- 5 files changed, 153 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b845a5f..0060687 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,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**. 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; du bestätigst oder korrigierst. 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`, …). +3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor; du bestätigst oder korrigierst. 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 (`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. ## Setup (Docker) diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index b378155..ffcebb2 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -1,4 +1,5 @@ import importlib +from pathlib import Path import pytest from fastapi.testclient import TestClient @@ -214,8 +215,9 @@ def test_scan_rel_dir_and_request_update(client): for i in range(2): (nested / f"t{i}.mp3").write_bytes(b"") item = client.get("/api/import/scan").json()["items"][0] - assert item["name"] == "CD1" - assert item["rel_dir"] == "Sammlung/Sonderfolgen" + # a disc subfolder is folded into its parent, which carries the title + assert item["name"] == "Sonderfolgen" + assert item["rel_dir"] == "Sammlung" req = client.post("/api/requests", json={"library_id": lib_id, "title": "X"}).json() updated = client.put(f"/api/requests/{req['id']}", json={ @@ -264,3 +266,48 @@ def test_retag_after_update(client): assert str(tags["TXXX:SERIES"]) == "Rowling Kinderbücher" assert str(tags["TXXX:SERIES-PART"]) == "2" assert str(tags["TCOM"]) == "Ben Becker" + + +def test_multi_disc_folder_is_one_audiobook(client): + root = client.tmp_path / "library" / "dr3i" + lib_id = client.post("/api/libraries", json={ + "name": "DR3i", "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": "Die dr3i - Böses Erwachen", + }).json()["id"] + + folder = client.downloads / "DiE DR3i - Boeses Erwachen" + for disc, tracks in (("CD 1", 3), ("CD2", 2)): + d = folder / disc + d.mkdir(parents=True) + for i in range(1, tracks + 1): + (d / f"Track {i}.mp3").write_bytes(b"") + + items = client.get("/api/import/scan").json()["items"] + assert len(items) == 1 + item = items[0] + assert item["name"] == "DiE DR3i - Boeses Erwachen" + assert item["is_dir"] and len(item["files"]) == 5 + # discs in order, tracks naturally sorted within a disc + assert [Path(f).parent.name for f in item["files"]] == ["CD 1"] * 3 + ["CD2"] * 2 + assert item["suggested_request_id"] == req_id + + res = client.post("/api/import", json={"items": [{ + "path": item["path"], "is_dir": True, "files": item["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 dr3i - Böses Erwachen - Part 0{i}.mp3" for i in range(1, 6)] + assert not folder.exists() + + +def test_natural_track_order_in_flat_folder(client): + folder = client.downloads / "Folge 12" + folder.mkdir(parents=True) + for i in (1, 2, 10): + (folder / f"track{i}.mp3").write_bytes(b"") + item = client.get("/api/import/scan").json()["items"][0] + assert [Path(f).name for f in item["files"]] == ["track1.mp3", "track2.mp3", "track10.mp3"] diff --git a/tests/test_matcher.py b/tests/test_matcher.py index cfacaae..d3e688a 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -47,3 +47,26 @@ def test_volume_number_must_match(): "media_type": "audiobook", "is_dir": False, "files": ["/d/f.mp3"]}] out = best_matches(items, [r1, r123]) assert out[0]["suggested_request_id"] == 2 + + +def test_leetspeak_title_matches(): + assert normalize("DiE DR3i - Folge 03") == "die drei folge 03" + # digits at a word edge stay put, they carry the episode number + assert normalize("Folge03 v2 [m4b]") == "folge03" + + r = req(1, "Die dr3i - Böses Erwachen", media_type="audiobook") + r.volume = 2 + items = [{"path": "/d/x", "name": "Die drei 02 - Boeses Erwachen", + "media_type": "audiobook", "is_dir": True, "files": ["/d/x/a.mp3"]}] + out = best_matches(items, [r]) + assert out[0]["suggested_request_id"] == 1 + + +def test_digit_inside_word_is_not_a_volume(): + from wordarr.importer.matcher import score + r = req(1, "Die dr3i - Folge 5", media_type="audiobook") + r.volume = 5 + # "dr3i" must not contribute a "3" that satisfies the volume check + r3 = req(2, "Die dr3i - Folge 3", media_type="audiobook") + r3.volume = 3 + assert score("DiE DR3i - 05 - Der Fall", r) > score("DiE DR3i - 05 - Der Fall", r3) diff --git a/wordarr/importer/matcher.py b/wordarr/importer/matcher.py index 5813bea..ab4cca0 100644 --- a/wordarr/importer/matcher.py +++ b/wordarr/importer/matcher.py @@ -3,16 +3,29 @@ import re from rapidfuzz import fuzz +# leetspeak inside a word, e.g. "DiE DR3i" vs "Die drei" +LEET = {"0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t"} + + +def _deleet(s: str) -> str: + """Undo leetspeak for digits enclosed by letters ("dr3i" -> "drei"). Digits at + a word edge stay ("folge 03", "folge03") - those are volume/episode numbers.""" + return re.sub(r"(?<=[a-z])([013457])(?=[a-z])", + lambda m: LEET[m.group(1)], s) + + def normalize(s: str) -> str: s = s.lower() s = re.sub(r"[._\-\[\]()]+", " ", s) s = re.sub(r"\b(epub|pdf|mobi|azw3|m4b|mp3|flac|cbz|cbr|retail|unabridged|audiobook|ebook|v\d+)\b", " ", s) + s = _deleet(s) s = re.sub(r"\s+", " ", s) return s.strip() def _numbers(s: str) -> set[int]: - return {int(n) for n in re.findall(r"\d+", s)} + # only standalone numbers; a digit inside a word ("dr3i") is not a volume + return {int(n) for n in re.findall(r"\b\d+\b", normalize(s))} def score(item_name: str, request) -> float: diff --git a/wordarr/importer/scanner.py b/wordarr/importer/scanner.py index 7937ed0..092dab1 100644 --- a/wordarr/importer/scanner.py +++ b/wordarr/importer/scanner.py @@ -1,7 +1,15 @@ +import re from pathlib import Path from .. import config +# "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. 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})$", + re.IGNORECASE, +) + def media_type_for(ext: str) -> str | None: ext = ext.lower() @@ -14,9 +22,20 @@ def media_type_for(ext: str) -> str | None: return None +def natural_key(name: str) -> list: + """Sort key so "Track 2" comes before "Track 10".""" + return [int(p) if p.isdigit() else p.lower() for p in re.split(r"(\d+)", name)] + + +def disc_number(name: str) -> int | None: + m = DISC_DIR_RE.match(name.strip()) + return int(m.group(1)) if m else None + + def scan(root: Path, split_dirs: bool = False) -> list[dict]: """Scan the download dir. Returns items: single files, or a directory that is - an audiobook unit (contains >1 audio file and nothing but audio/junk). + an audiobook unit (contains >1 audio file, or disc subfolders like CD1/CD2, + and nothing but audio/junk). With split_dirs=True every file is listed individually (e.g. for folders holding many episodes of a series).""" items = [] @@ -25,10 +44,31 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]: def audio_files(d: Path) -> list[Path]: return sorted( - f for f in d.iterdir() - if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS + (f for f in d.iterdir() + if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS), + key=lambda f: natural_key(f.name), ) + def subdirs(d: Path) -> list[Path]: + return [s for s in d.iterdir() if s.is_dir() and not s.name.startswith(".")] + + def disc_audio(d: Path) -> list[Path] | None: + """Audio files of d's disc subfolders (CD1, CD2, …), in disc order. + None if d is not a multi-disc folder.""" + subs = subdirs(d) + if not subs: + return None + discs = [] + for s in subs: + num = disc_number(s.name) + if num is None or not audio_files(s): + return None # not purely a disc split -> walk normally + discs.append((num, s)) + files = [] + for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))): + files.extend(audio_files(s)) + return files or None + def rel_dir(d: Path) -> str: try: return str(d.relative_to(root)) if d != root else "" @@ -37,17 +77,29 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]: def walk(d: Path): audio = audio_files(d) - if len(audio) > 1 and not split_dirs: - items.append({ - "path": str(d), - "name": d.name, - "rel_dir": rel_dir(d.parent), - "media_type": "audiobook", - "is_dir": True, - "files": [str(f) for f in audio], - }) - return - for entry in sorted(d.iterdir()): + if not split_dirs: + discs = disc_audio(d) if d != root else None + if discs: + items.append({ + "path": str(d), + "name": d.name, + "rel_dir": rel_dir(d.parent), + "media_type": "audiobook", + "is_dir": True, + "files": [str(f) for f in audio + discs], + }) + return + if len(audio) > 1: + items.append({ + "path": str(d), + "name": d.name, + "rel_dir": rel_dir(d.parent), + "media_type": "audiobook", + "is_dir": True, + "files": [str(f) for f in audio], + }) + return + for entry in sorted(d.iterdir(), key=lambda p: natural_key(p.name)): if entry.name.startswith("."): continue if entry.is_dir():