fix(import): stop several entries from claiming one request
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+60
-14
@@ -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 = '<span class="spinner"></span> 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 = "") =>
|
||||
`<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
|
||||
.filter((r) => r.media_type === item.media_type)
|
||||
.map(
|
||||
(r) =>
|
||||
`<option value="${r.id}" ${r.id === item.request_id ? "selected" : ""}>
|
||||
${esc(r.title)} — ${esc(r.authors)} (${esc(r.library_name)})</option>`
|
||||
)
|
||||
.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 `<tr>
|
||||
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
||||
@@ -716,7 +724,9 @@ function renderImportTable() {
|
||||
</td>
|
||||
<td>${esc(item.media_type)}</td>
|
||||
<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>
|
||||
${item.parts ? `<button class="secondary" data-split="${i}" title="Zusammenfassung wieder auflösen">✂️</button>` : ""}
|
||||
</td>
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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()
|
||||
|
||||
+24
-2
@@ -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
|
||||
|
||||
+55
-20
@@ -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)
|
||||
|
||||
+31
-15
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user