fix(import): stop several entries from claiming one request

This commit is contained in:
Steppenstreuner
2026-08-28 21:01:07 +02:00
parent a816dcb27a
commit 60b9e44a11
6 changed files with 280 additions and 52 deletions
+1 -1
View File
@@ -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**. 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. 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`). 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) ## Setup (Docker)
+60 -14
View File
@@ -621,6 +621,7 @@ $("#detail-form").addEventListener("submit", async (e) => {
// ---- import ---- // ---- import ----
let scanItems = []; let scanItems = [];
let missingReqs = []; let missingReqs = [];
let importedReqs = [];
let viewIdx = []; // indices into scanItems after filter/sort let viewIdx = []; // indices into scanItems after filter/sort
let importPage = 0; let importPage = 0;
const IMPORT_PAGE_SIZE = 25; const IMPORT_PAGE_SIZE = 25;
@@ -631,9 +632,10 @@ $("#scan-btn").addEventListener("click", async () => {
$("#scan-info").innerHTML = '<span class="spinner"></span> Scanne…'; $("#scan-info").innerHTML = '<span class="spinner"></span> Scanne…';
try { try {
const split = $("#split-dirs").checked; 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/import/scan?split_dirs=" + split),
api("/api/requests?status=missing"), api("/api/requests?status=missing"),
api("/api/requests?status=imported"),
]); ]);
scanItems = scan.items; scanItems = scan.items;
// selection state lives here, not in the DOM, so it survives paging/filtering // 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; item.checked = !!item.suggested_request_id;
}); });
missingReqs = reqs; missingReqs = reqs;
importedReqs = done;
importPage = 0; importPage = 0;
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`; $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
$("#import-toolbar").hidden = scanItems.length === 0; $("#import-toolbar").hidden = scanItems.length === 0;
@@ -699,13 +702,18 @@ function renderImportTable() {
tbody.innerHTML = pageIdx tbody.innerHTML = pageIdx
.map((i) => { .map((i) => {
const item = scanItems[i]; const item = scanItems[i];
const option = (r, suffix = "") =>
`<option value="${r.id}" ${r.id === item.request_id ? "selected" : ""}>
${esc(r.title)}${esc(r.authors)} (${esc(r.library_name)})${suffix}</option>`;
const opts = missingReqs const opts = missingReqs
.filter((r) => r.media_type === item.media_type) .filter((r) => r.media_type === item.media_type)
.map( .map((r) => option(r))
(r) => .join("");
`<option value="${r.id}" ${r.id === item.request_id ? "selected" : ""}> // already imported audiobooks can take further parts (a late CD, or one
${esc(r.title)}${esc(r.authors)} (${esc(r.library_name)})</option>` // 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(""); .join("");
return `<tr> return `<tr>
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td> <td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
@@ -716,7 +724,9 @@ function renderImportTable() {
</td> </td>
<td>${esc(item.media_type)}</td> <td>${esc(item.media_type)}</td>
<td class="row"> <td class="row">
<select data-select="${i}"><option value="">— überspringen —</option>${opts}</select> <select data-select="${i}"><option value="">— überspringen —</option>${opts}
${appendOpts ? `<optgroup label="Bereits importiert — Teile anhängen">${appendOpts}</optgroup>` : ""}
</select>
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button> <button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
${item.parts ? `<button class="secondary" data-split="${i}" title="Zusammenfassung wieder auflösen">✂️</button>` : ""} ${item.parts ? `<button class="secondary" data-split="${i}" title="Zusammenfassung wieder auflösen">✂️</button>` : ""}
</td> </td>
@@ -1002,14 +1012,50 @@ async function pickQuickResult(r) {
// ---- import execution (batched, with progress) ---- // ---- import execution (batched, with progress) ----
const IMPORT_BATCH_SIZE = 20; 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 () => { $("#import-btn").addEventListener("click", async () => {
const items = scanItems const chosen = scanItems.filter((item) => item.checked && item.request_id);
.filter((item) => item.checked && item.request_id) if (!chosen.length) { toast("Nichts ausgewählt", true); return; }
.map((item) => ({ const items = collapseSharedRequests(chosen);
path: item.path, is_dir: item.is_dir, files: item.files, if (!items) return;
request_id: item.request_id,
}));
if (!items.length) { toast("Nichts ausgewählt", true); return; }
const btn = $("#import-btn"); const btn = $("#import-btn");
btn.disabled = true; btn.disabled = true;
const allResults = []; const allResults = [];
+109
View File
@@ -538,3 +538,112 @@ def test_merged_parts_clean_up_nested_source_folders(client):
assert not (base / "A - Sphinx").exists() assert not (base / "A - Sphinx").exists()
assert not (base / "B - Volk").exists() assert not (base / "B - Volk").exists()
assert (base / "Cover" / "front.jpg").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()
+24 -2
View File
@@ -30,6 +30,8 @@ class ImportItem(BaseModel):
is_dir: bool is_dir: bool
files: list[str] files: list[str]
request_id: int request_id: int
# add to an already imported audiobook instead of importing a new one
append: bool = False
class ImportIn(BaseModel): class ImportIn(BaseModel):
@@ -42,8 +44,28 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
download_root = Path(config.DOWNLOAD_DIR).resolve() download_root = Path(config.DOWNLOAD_DIR).resolve()
for item in data.items: for item in data.items:
req = session.get(BookRequest, item.request_id) req = session.get(BookRequest, item.request_id)
if not req or req.status != "missing": if not req:
results.append({"path": item.path, "ok": False, "error": "request not found or not missing"}) 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 continue
# every source path must stay inside the download dir - merged items # every source path must stay inside the download dir - merged items
# carry files from several folders, so check them all # carry files from several folders, so check them all
+55 -20
View File
@@ -6,6 +6,27 @@ from ..naming import render_template, sanitize
from . import tagger 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: 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. """Move scanned files into the library, renamed per the library's templates.
Returns the destination path (folder for multi-file audiobooks, else the file).""" 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) shutil.move(str(src), dest)
if request.media_type == "audiobook": if request.media_type == "audiobook":
tagger.tag_audio(dest, request, track=i, total=len(paths)) tagger.tag_audio(dest, request, track=i, total=len(paths))
# remove emptied source folders: the item itself plus the disc/part _cleanup_sources(Path(item_path), paths)
# 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)
return str(folder) return str(folder)
src = paths[0] 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": if request.media_type == "audiobook":
tagger.tag_audio(dest, request) tagger.tag_audio(dest, request)
return str(dest) 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)
+31 -15
View File
@@ -3,13 +3,17 @@ from pathlib import Path
from .. import config from .. import config
# "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. The number is # "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Teil B" as folder name. The index is
# optional (single-disc rips just use "CD"). A bare number is deliberately not a # optional (single-disc rips just use "CD") and may be a letter. A bare number is
# disc: those folders are usually episodes of a series. # deliberately not a disc: those folders are usually episodes of a series.
DISC_DIR_RE = re.compile( 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, 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: 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)] 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: def disc_number(name: str) -> int | None:
"""Disc index of a disc folder name, 0 for an unnumbered "CD". None if the """Disc index of a disc folder name, 0 for an unnumbered "CD", 1-8 for a
name is not a disc folder at all.""" letter ("Teil B"). None if the name is not a disc folder at all."""
m = DISC_DIR_RE.match(name.strip()) m = DISC_DIR_RE.match(name.strip())
if not m: if not m:
return None 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]: 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 """Audio files of d's disc subfolders ("CD", "CD1", "CD2", …), in disc
order. Subfolders without audio (Cover, Scans, …) are ignored. None if d order. Subfolders without audio (Cover, Scans, …) are ignored. None if d
is not a disc-split folder.""" is not a disc-split folder."""
discs = [] audio_subs = [s for s in subdirs(d) if has_audio(s)]
for s in subdirs(d): # artwork/booklet folders are already filtered out above
if not has_audio(s): discs = [(disc_number(s.name), s) for s in audio_subs]
continue # artwork/booklet folder, not part of the audiobook if any(num is None for num, _ in discs):
num = disc_number(s.name) # not disc folders - but "A - …", "B - …" are parts of one story too,
if num is None: # as long as every single one of them follows that shape
return None # a real subfolder -> not a disc split, walk normally letters = [(letter_part(s.name), s) for s in audio_subs]
discs.append((num, s)) if len(letters) < 2 or any(num is None for num, _ in letters):
return None # real subfolders -> walk normally
discs = letters
files = [] files = []
for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))): for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))):
files.extend(audio_files(s)) files.extend(audio_files(s))