diff --git a/README.md b/README.md index 7d8bf19..ed807f3 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. Was Audible gar nicht führt (Live-Hörspiele, Fan-Editionen), legst du im selben Dialog unter *Nicht bei Audible? Manuell anlegen* an — Titel und Folge sind aus dem Ordnernamen vorbelegt, die Serie bleibt für den nächsten Eintrag stehen. 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. +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. Was Audible gar nicht führt (Live-Hörspiele, Fan-Editionen), legst du im selben Dialog unter *Nicht bei Audible? Manuell anlegen* an — Titel und Folge sind aus dem Ordnernamen vorbelegt, die Serie bleibt für den nächsten Eintrag stehen. 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`, …). Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf; wordarr markiert solche Ordner mit `n Titel?`, wenn die Dateinamen verschiedene Titel tragen und jede Datei groß genug für ein ganzes Buch ist. Die Checkbox *Ordner als Einzeldateien behandeln* macht dasselbe für den gesamten Scan. 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) @@ -23,6 +23,13 @@ docker compose up -d --build | `WORDARR_DOWNLOAD_DIR` | `/mnt/downloads` | Gescannter Download-Ordner | | `WORDARR_CONFIG_DIR` | `/config` | Ablage der SQLite-DB | +## Metadaten korrigieren + +Audible führt Reihen manchmal anders, als man sie ablegen möchte (*Harry Potter* steht dort unter *Wizarding World*, und das landet über `{Series}` im Ordnernamen). Deshalb: + +- **Vor dem Anlegen**: Im Import-Dialog übernimmt ✎ an einem Treffer dessen Felder (Titel, Autor, Serie, Band) in die Eingabemaske — Sprecher, Cover, Jahr und ASIN bleiben erhalten. Erst *Anlegen & verbinden* erzeugt die Anfrage. +- **Danach**: Im Tab *Missing* bzw. *Importiert* öffnet ein Klick auf die Karte alle Felder. *Speichern & neu taggen* schreibt die ID3-Tags neu, **Speichern & neu ablegen** wendet das Namensschema der Library auf die bereits verschobenen Dateien an — der Ordner wird also umbenannt bzw. verschoben, Cover ziehen mit um und leer gewordene Ordner verschwinden. Ein belegter Zielordner bricht den Vorgang ab, statt etwas zu überschreiben. + ## Sprachen Jede Library kann eine **Sprache** tragen (`Deutsch`/`Englisch`, Default: egal). Audible liefert zu jedem Titel die Sprache mit, deshalb zeigt die Suche für eine englische Library nur englische Ausgaben und für eine deutsche nur deutsche — praktisch, wenn dieselbe Reihe in beiden Sprachen in getrennten Libraries liegt (*A Song of Ice and Fire* vs. *Das Lied von Eis und Feuer*). Gesucht wird dann auch auf dem passenden Marktplatz (`audible.com` bzw. `audible.de`), was die Trefferqualität deutlich hebt. Im Tab *Suche* lässt sich die Sprache zusätzlich frei filtern; im Import-Dialog kommt sie automatisch aus der gewählten Ziel-Library. diff --git a/static/app.js b/static/app.js index 8629a9b..861fcf8 100644 --- a/static/app.js +++ b/static/app.js @@ -576,6 +576,7 @@ function openDetail(id, status) { $("#detail-nocover").hidden = !!r.cover_url; if (r.cover_url) img.src = r.cover_url; $("#detail-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook"); + $("#detail-relocate").hidden = r.status !== "imported"; $("#detail-dialog").showModal(); } @@ -596,6 +597,26 @@ async function saveDetail(form) { }); } +// after correcting series/title/author the files are still filed under the old +// name - this re-applies the library's scheme to what is already on disk +$("#detail-relocate").addEventListener("click", async () => { + if (!detailRequest) return; + const btn = $("#detail-relocate"); + btn.disabled = true; + btn.innerHTML = ' Lege ab…'; + try { + await saveDetail($("#detail-form")); + const res = await api(`/api/requests/${detailRequest.id}/relocate`, { method: "POST" }); + toast(res.moved ? `Neu abgelegt: ${res.dest}` : "Liegt bereits richtig"); + $("#detail-dialog").close(); + loadRequests(detailRequest.status); + } catch (err) { toast(err.message, true); } + finally { + btn.disabled = false; + btn.textContent = "Speichern & neu ablegen"; + } +}); + $("#detail-retag").addEventListener("click", async () => { if (!detailRequest) return; const btn = $("#detail-retag"); @@ -726,6 +747,9 @@ function renderImportTable() { ${esc(item.name)}${item.is_dir ? " 📁" : ""} ${item.parts ? `${item.parts.length} Teile · ${item.files.length} Dateien` : ""} + ${!item.parts && item.maybe_separate + ? `${item.files.length} Titel?` + : ""} ${item.rel_dir ? `
${esc(item.rel_dir)}/
` : ""} ${esc(item.media_type)} @@ -734,7 +758,9 @@ function renderImportTable() { ${appendOpts ? `${appendOpts}` : ""} - ${item.parts ? `` : ""} + ${item.parts || (item.is_dir && item.files.length > 1) + ? `` + : ""} ${scoreCell(item)} `; @@ -747,7 +773,7 @@ function renderImportTable() { b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick))) ); tbody.querySelectorAll("[data-split]").forEach((b) => - b.addEventListener("click", () => splitMergedItem(parseInt(b.dataset.split))) + b.addEventListener("click", () => splitItem(parseInt(b.dataset.split))) ); tbody.querySelectorAll("[data-check]").forEach((c) => c.addEventListener("change", () => { @@ -857,16 +883,37 @@ $("#import-merge").addEventListener("click", () => { renderImportTable(); }); -function splitMergedItem(i) { +function splitItem(i) { const item = scanItems[i]; - if (!item.parts) return; - scanItems.splice(i, 1, ...item.parts); + if (item.parts) { // undo a merge + scanItems.splice(i, 1, ...item.parts); + } else if (item.is_dir && item.files.length > 1) { + // a folder holding one book per file (a series bought as single titles): + // turn every file into its own entry so each can get its own request + const parts = item.files.map((f) => ({ + path: f, + name: f.split("/").pop().replace(/\.[^.]+$/, ""), + rel_dir: item.rel_dir ? `${item.rel_dir}/${item.name}` : item.name, + media_type: item.media_type, + is_dir: false, + files: [f], + checked: false, + request_id: null, + suggested_request_id: null, + score: null, + })); + scanItems.splice(i, 1, ...parts); + toast(`„${item.name}" in ${parts.length} Einzeltitel aufgeteilt`); + } else { + return; + } renderImportTable(); } // ---- quick request from a scanned file ---- let quickItemIndex = null; let quickEpisode = null; +let quickPicked = null; // metadata hit the manual fields were filled from let lastSeries = ""; // "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest. @@ -908,9 +955,13 @@ function openQuickDialog(i) { $("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name; $("#quick-episode").textContent = number != null ? `Folge ${number} — nicht mitgesucht` : ""; $("#quick-q").value = cleanFileName(rest); + quickPicked = null; $("#quick-m-title").value = cleanFileName(rest); + $("#quick-m-authors").value = ""; $("#quick-m-volume").value = number ?? ""; $("#quick-m-series").value = lastSeries; + $("#quick-based-on").hidden = true; + $("#quick-manual-summary").textContent = "Nicht bei Audible? Manuell anlegen"; $("#quick-manual").open = false; $("#quick-library").innerHTML = libOptions(item.media_type) || ""; @@ -988,6 +1039,7 @@ async function runQuickSearch() { ${esc(r.title)} ${meta} + `; }) @@ -995,6 +1047,9 @@ async function runQuickSearch() { box.querySelectorAll("[data-pick]").forEach((btn) => btn.addEventListener("click", () => pickQuickResult(results[btn.dataset.pick])) ); + box.querySelectorAll("[data-edit]").forEach((btn) => + btn.addEventListener("click", () => editBeforeRequest(results[btn.dataset.edit])) + ); } catch (err) { box.innerHTML = ""; toast(err.message, true); @@ -1033,13 +1088,35 @@ function pickQuickResult(r) { }); } -// live shows, box sets and fan releases are not in the Audible catalogue at all +// take a hit into the fields so title/author/series can be corrected before the +// request is created - Audible files Harry Potter under "Wizarding World", and +// the series ends up in the folder name +function editBeforeRequest(r) { + quickPicked = r; + $("#quick-m-title").value = r.title || ""; + $("#quick-m-authors").value = r.authors || ""; + $("#quick-m-series").value = r.series || ""; + $("#quick-m-volume").value = r.volume ?? quickEpisode ?? ""; + $("#quick-based-on").textContent = + `Übernommen von Audible: „${r.title}" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.`; + $("#quick-based-on").hidden = false; + $("#quick-manual-summary").textContent = "Felder anpassen"; + $("#quick-manual").open = true; + $("#quick-m-title").focus(); +} + $("#quick-m-submit").addEventListener("click", () => { const title = $("#quick-m-title").value.trim(); if (!title) { toast("Titel angeben", true); return; } const volume = $("#quick-m-volume").value; + const base = quickPicked + ? { narrator: quickPicked.narrator || "", external_id: quickPicked.external_id, + year: quickPicked.year, cover_url: quickPicked.cover_url } + : {}; return requestAndConnect({ + ...base, title, + authors: $("#quick-m-authors").value.trim(), series: (lastSeries = $("#quick-m-series").value.trim()), volume: volume ? parseInt(volume) : null, }); diff --git a/static/index.html b/static/index.html index 9be7586..01d66d9 100644 --- a/static/index.html +++ b/static/index.html @@ -213,9 +213,13 @@
- Nicht bei Audible? Manuell anlegen + + Nicht bei Audible? Manuell anlegen + +
+ @@ -316,6 +320,10 @@ +
diff --git a/static/style.css b/static/style.css index 2285d4e..0a4596a 100644 --- a/static/style.css +++ b/static/style.css @@ -54,6 +54,7 @@ button:disabled { opacity: 0.6; cursor: default; } .muted { color: var(--muted); font-size: 0.88rem; } .mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; } .subpath { color: var(--muted); font-size: 0.75rem; font-family: ui-monospace, monospace; } +.tag.warn { background: #8a6d1f; } .tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; } /* view modes (Missing/Importiert) */ diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 3795715..872ca4d 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -687,3 +687,136 @@ def test_search_passes_language_to_the_provider(client, monkeypatch): }).json() assert seen == {"query": "Game of Thrones", "language": "english"} assert res[0]["language"] == "english" + + +def test_folder_of_separate_books_is_flagged(client): + """One book per file (a series bought as single titles) still scans as one + entry - but gets flagged so the UI can offer to split it.""" + got = client.downloads / "George R. R. Martin" / "A Game of Thrones" + got.mkdir(parents=True) + for name in ("1 - A Game of Thrones- A Song of Ice and Fire, Book 1 (Unabridged)", + "2 - A Clash of Kings- A Song of Ice and Fire, Book 2 (Unabridged)", + "A - The World of Ice & Fire - The Untold History of Westeros"): + with open(got / f"{name}.mp3", "wb") as f: + f.truncate(700 * 1024 * 1024) # sparse, costs no disk space + + chapters = client.downloads / "Ein Hoerbuch" + chapters.mkdir() + for i, title in enumerate(["Prolog", "Die Ankunft", "Das Ende"], 1): + with open(chapters / f"{i:02d} - {title}.mp3", "wb") as f: + f.truncate(25 * 1024 * 1024) + + tracks = client.downloads / "Folge 100" + tracks.mkdir() + for i in (1, 2, 3): + with open(tracks / f"{i:02d} Track.mp3", "wb") as f: + f.truncate(200 * 1024 * 1024) + + flags = {i["name"]: i["maybe_separate"] + for i in client.get("/api/import/scan").json()["items"]} + assert flags == { + "A Game of Thrones": True, # different titles, each big enough + "Ein Hoerbuch": False, # different titles, but chapter sized + "Folge 100": False, # same name beyond the index + } + + +def test_split_dirs_still_lists_every_file(client): + got = client.downloads / "A Game of Thrones" + got.mkdir() + for n in (1, 2, 3): + (got / f"{n} - Book {n}.mp3").write_bytes(b"") + items = client.get("/api/import/scan", params={"split_dirs": True}).json()["items"] + assert len(items) == 3 + assert all(not i["is_dir"] for i in items) + + +def test_relocate_after_fixing_the_series(client): + """Audible files Harry Potter under "Wizarding World"; after correcting the + series the folder on disk should follow.""" + root = client.tmp_path / "library" / "english" + lib_id = client.post("/api/libraries", json={ + "name": "english", "media_type": "audiobook", "root_path": str(root), + "folder_template": "{Series}/{Title}", "file_template": "{Title}", + }).json()["id"] + req_id = client.post("/api/requests", json={ + "library_id": lib_id, "title": "Philosopher's Stone", + "series": "Wizarding World", "volume": 1, + }).json()["id"] + + src = client.downloads / "hp1" + src.mkdir() + for i in (1, 2): + (src / f"{i:02d}.mp3").write_bytes(b"") + dest = client.post("/api/import", json={"items": [{ + "path": str(src), "is_dir": True, + "files": [str(f) for f in sorted(src.iterdir())], "request_id": req_id, + }]}).json()["results"][0]["dest"] + assert Path(dest) == root / "Wizarding World" / "Philosopher's Stone" + # a cover dropped next to the audio must survive the move + (Path(dest) / "cover.jpg").write_bytes(b"") + + client.put(f"/api/requests/{req_id}", json={ + "library_id": lib_id, "title": "Philosopher's Stone", + "series": "Harry Potter", "volume": 1, + }) + res = client.post(f"/api/requests/{req_id}/relocate").json() + assert res["ok"] and res["moved"] + new = root / "Harry Potter" / "Philosopher's Stone" + assert Path(res["dest"]) == new + assert sorted(p.name for p in new.iterdir()) == [ + "Philosopher's Stone - Part 01.mp3", "Philosopher's Stone - Part 02.mp3", "cover.jpg", + ] + assert not (root / "Wizarding World").exists() # emptied parent is gone + assert client.get("/api/requests", params={"status": "imported"}).json()[0][ + "imported_path"] == str(new) + + +def test_relocate_is_a_no_op_when_nothing_changed(client): + root = client.tmp_path / "library" / "l" + lib_id = client.post("/api/libraries", json={ + "name": "L", "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": "Ein Buch"}).json()["id"] + src = client.downloads / "buch" + src.mkdir() + for i in (1, 2): + (src / f"{i:02d}.mp3").write_bytes(b"") + client.post("/api/import", json={"items": [{ + "path": str(src), "is_dir": True, + "files": [str(f) for f in sorted(src.iterdir())], "request_id": req_id, + }]}) + res = client.post(f"/api/requests/{req_id}/relocate").json() + assert res["ok"] and not res["moved"] + assert sorted(p.name for p in (root / "Ein Buch").iterdir()) == [ + "Ein Buch - Part 01.mp3", "Ein Buch - Part 02.mp3", + ] + + +def test_relocate_refuses_to_overwrite(client): + root = client.tmp_path / "library" / "l" + lib_id = client.post("/api/libraries", json={ + "name": "L", "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": "Erstes"}).json()["id"] + src = client.downloads / "b" + src.mkdir() + (src / "01.mp3").write_bytes(b"") + (src / "02.mp3").write_bytes(b"") + client.post("/api/import", json={"items": [{ + "path": str(src), "is_dir": True, + "files": [str(f) for f in sorted(src.iterdir())], "request_id": req_id, + }]}) + occupied = root / "Zweites" + occupied.mkdir(parents=True) + (occupied / "fremd.mp3").write_bytes(b"") + + client.put(f"/api/requests/{req_id}", json={"library_id": lib_id, "title": "Zweites"}) + res = client.post(f"/api/requests/{req_id}/relocate") + assert res.status_code == 409 + assert (occupied / "fremd.mp3").exists() + assert (root / "Erstes" / "Erstes - Part 01.mp3").exists() diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py index 95456a2..ba1b039 100644 --- a/wordarr/api/requests.py +++ b/wordarr/api/requests.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from .. import config from ..db import BookRequest, Library, get_session -from ..importer import tagger +from ..importer import mover, tagger router = APIRouter(prefix="/api/requests", tags=["requests"]) @@ -221,6 +221,27 @@ def retag_request(request_id: int, session: Session = Depends(get_session)): raise HTTPException(404, f"imported path no longer exists: {path}") +@router.post("/{request_id}/relocate") +def relocate_request(request_id: int, session: Session = Depends(get_session)): + """Re-apply the library's naming scheme to an imported title, e.g. after + fixing a series that Audible had filed differently.""" + req = session.get(BookRequest, request_id) + if not req: + raise HTTPException(404, "request not found") + if req.status != "imported" or not req.imported_path: + raise HTTPException(400, "request is not imported yet") + old = req.imported_path + try: + dest = mover.relocate(req, req.library) + except FileNotFoundError as exc: + raise HTTPException(404, str(exc)) + except FileExistsError as exc: + raise HTTPException(409, str(exc)) + req.imported_path = dest + session.commit() + return {"ok": True, "moved": dest != old, "from": old, "dest": dest} + + @router.delete("/{request_id}") def delete_request(request_id: int, session: Session = Depends(get_session)): req = session.get(BookRequest, request_id) diff --git a/wordarr/importer/mover.py b/wordarr/importer/mover.py index 4887004..c527dbb 100644 --- a/wordarr/importer/mover.py +++ b/wordarr/importer/mover.py @@ -87,3 +87,68 @@ def append_to_import(files: list[str], request, library) -> str: for src_dir in {p.parent for p in paths}: _cleanup_sources(src_dir, paths) return str(dest_dir) + + +def relocate(request, library) -> str: + """Move an already imported audiobook to where the library's templates say it + belongs now - after correcting series, title or author. Files are renamed as + on import, extras (cover art) move along, and emptied folders are removed.""" + src = Path(request.imported_path or "") + if not src.exists(): + raise FileNotFoundError(f"Pfad existiert nicht (mehr): {request.imported_path}") + + root = Path(library.root_path) + folder = root / render_template(library.folder_template, request) + base = render_template(library.file_template, request) + + if src.is_file(): + dest = folder / sanitize(f"{base}{src.suffix.lower()}") + if dest.resolve() == src.resolve(): + return str(src) + if dest.exists(): + raise FileExistsError(f"Destination already exists: {dest}") + folder.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), dest) + if request.media_type == "audiobook": + tagger.tag_audio(dest, request) + _remove_if_empty(src.parent, root) + return str(dest) + + audio = sorted( + (f for f in src.iterdir() + if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS), + key=lambda f: f.name, + ) + extras = [f for f in src.iterdir() if f.is_file() and f not in audio] + same_folder = folder.resolve() == src.resolve() if folder.exists() else False + if not same_folder and folder.exists() and any(folder.iterdir()): + raise FileExistsError(f"Destination already exists: {folder}") + folder.mkdir(parents=True, exist_ok=True) + + width = max(2, len(str(len(audio)))) + for i, f in enumerate(audio, 1): + name = sanitize( + f"{base} - Part {i:0{width}d}{f.suffix.lower()}" if len(audio) > 1 + else f"{base}{f.suffix.lower()}" + ) + dest = folder / name + if dest.resolve() != f.resolve(): + shutil.move(str(f), dest) + if request.media_type == "audiobook": + tagger.tag_audio(dest, request, track=i, total=len(audio)) + for extra in extras: + target = folder / extra.name + if target.resolve() != extra.resolve() and not target.exists(): + shutil.move(str(extra), target) + if not same_folder: + _remove_if_empty(src, root) + return str(folder) + + +def _remove_if_empty(folder: Path, root: Path) -> None: + """Drop the folder and its emptied parents, never touching the library root.""" + while folder.is_dir() and folder.resolve() != root.resolve() and root in folder.parents: + if any(folder.iterdir()): + return + folder.rmdir() + folder = folder.parent diff --git a/wordarr/importer/scanner.py b/wordarr/importer/scanner.py index 2c4a4e0..3151e3d 100644 --- a/wordarr/importer/scanner.py +++ b/wordarr/importer/scanner.py @@ -14,6 +14,14 @@ DISC_DIR_RE = re.compile( # 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) +# leading track/part index of a file name: "01 - ", "1. ", "[A] ", "B - ", "01 ". +# Only digits or a single letter count, so a title starting with a short word +# ("Der Hobbit …") keeps its first word. +LEADING_INDEX_RE = re.compile( + r"^\s*[\[(]?(?:\d{1,4}|[a-z])[\])]?\s*(?:[-–—._:]+\s*|\s+)", re.IGNORECASE +) +# a whole book in one file is big; chapter/track files are not +SEPARATE_TITLE_MIN_BYTES = 60 * 1024 * 1024 def media_type_for(ext: str) -> str | None: @@ -51,6 +59,20 @@ def letter_part(name: str) -> int | None: return _index(m.group(1)) if m else None +def separate_titles(files: list[Path]) -> bool: + """True when a folder's audio files look like distinct books rather than the + parts of one audiobook: their names differ beyond a leading index, and each + file is large enough to be a whole book. Only a hint for the UI - the split + stays a manual decision, because chapter-per-file rips look similar.""" + residuals = {LEADING_INDEX_RE.sub("", f.stem).strip().lower() for f in files} + if len(residuals) < 2: + return False + try: + return all(f.stat().st_size >= SEPARATE_TITLE_MIN_BYTES for f in files) + except OSError: + return False + + 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, or disc subfolders like CD1/CD2, @@ -114,6 +136,7 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]: "media_type": "audiobook", "is_dir": True, "files": [str(f) for f in audio + discs], + "maybe_separate": False, # disc folders are one book by definition }) return if len(audio) > 1: @@ -124,6 +147,7 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]: "media_type": "audiobook", "is_dir": True, "files": [str(f) for f in audio], + "maybe_separate": separate_titles(audio), }) return for entry in sorted(d.iterdir(), key=lambda p: natural_key(p.name)):