fix(import): treat cd1/cd2 folders as one audiobook

This commit is contained in:
Steppenstreuner
2026-08-28 19:21:24 +02:00
parent 24e3e9658e
commit b8f042025d
5 changed files with 153 additions and 18 deletions
+1 -1
View File
@@ -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)
+49 -2
View File
@@ -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"]
+23
View File
@@ -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)
+14 -1
View File
@@ -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:
+66 -14
View File
@@ -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():