add(import): merge multi-part entries into one audiobook

This commit is contained in:
Steppenstreuner
2026-08-28 20:33:26 +02:00
parent 951f68ca7d
commit 36263d66fb
7 changed files with 173 additions and 7 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**.
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.
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).
## Setup (Docker)
+61
View File
@@ -711,12 +711,14 @@ function renderImportTable() {
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
<td class="mono">
${esc(item.name)}${item.is_dir ? " 📁" : ""}
${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""}
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
</td>
<td>${esc(item.media_type)}</td>
<td class="row">
<select data-select="${i}"><option value="">— überspringen —</option>${opts}</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>
${scoreCell(item)}
</tr>`;
@@ -728,6 +730,9 @@ function renderImportTable() {
tbody.querySelectorAll("[data-quick]").forEach((b) =>
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
);
tbody.querySelectorAll("[data-split]").forEach((b) =>
b.addEventListener("click", () => splitMergedItem(parseInt(b.dataset.split)))
);
tbody.querySelectorAll("[data-check]").forEach((c) =>
c.addEventListener("change", () => {
scanItems[c.dataset.check].checked = c.checked;
@@ -783,6 +788,62 @@ $("#import-deselect-all").addEventListener("click", () => {
renderImportTable();
});
// ---- multi-part episodes: import several entries as one audiobook ----
// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title
function commonPrefix(names) {
let prefix = names[0];
for (const n of names.slice(1)) {
let i = 0;
while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++;
prefix = prefix.slice(0, i);
}
// drop a dangling part marker: "100 - Toteninsel Teil " -> "100 - Toteninsel"
return prefix.replace(/[\s._\-–—:]*(?:teil|part|cd|disc|folge)?[\s._\-–—:]*$/i, "").trim();
}
$("#import-merge").addEventListener("click", () => {
const chosen = scanItems.filter((it) => it.checked);
if (chosen.length < 2) {
toast("Mindestens zwei Einträge auswählen", true);
return;
}
if (new Set(chosen.map((it) => it.media_type)).size > 1) {
toast("Nur Einträge desselben Typs lassen sich zusammenfassen", true);
return;
}
const parents = new Set(chosen.map((it) => it.rel_dir));
if (parents.size > 1) {
toast("Die Einträge liegen in verschiedenen Ordnern", true);
return;
}
const name = commonPrefix(chosen.map((it) => it.name)) || chosen[0].name;
const merged = {
path: chosen[0].path.slice(0, chosen[0].path.length - chosen[0].name.length - 1) || chosen[0].path,
name,
rel_dir: chosen[0].rel_dir,
media_type: chosen[0].media_type,
is_dir: true,
files: chosen.flatMap((it) => it.files),
checked: true,
request_id: chosen.find((it) => it.request_id)?.request_id ?? null,
suggested_request_id: chosen.find((it) => it.suggested_request_id)?.suggested_request_id ?? null,
score: Math.max(...chosen.map((it) => it.score ?? 0)),
parts: chosen,
};
const first = scanItems.indexOf(chosen[0]);
scanItems = scanItems.filter((it) => !chosen.includes(it));
scanItems.splice(first, 0, merged);
toast(`${chosen.length} Einträge als „${name}" zusammengefasst (${merged.files.length} Dateien)`);
renderImportTable();
});
function splitMergedItem(i) {
const item = scanItems[i];
if (!item.parts) return;
scanItems.splice(i, 1, ...item.parts);
renderImportTable();
}
// ---- quick request from a scanned file ----
let quickItemIndex = null;
let quickEpisode = null;
+3
View File
@@ -162,6 +162,9 @@
<button id="import-deselect-all" class="secondary">
Alle abwählen
</button>
<button id="import-merge" class="secondary" title="Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren">
Ausgewählte zusammenfassen
</button>
</div>
<div class="table-scroll">
<table id="import-table" hidden>
+1
View File
@@ -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 { 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) */
.view-toggle { display: flex; gap: 0; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; margin-left: auto; }
+90
View File
@@ -414,3 +414,93 @@ def test_bulk_items_series_then_scan_matches_by_episode_number(client):
n = int(item["name"][:3])
assert item["suggested_request_id"] == by_volume[n], item
assert item["score"] >= 90
def test_merged_multipart_import(client):
"""A multi-part episode ("Teil 1/2/3") imported as one audiobook: the client
merges the scanned entries and posts their files as a single item."""
root = client.tmp_path / "library" / "ddf"
lib_id = client.post("/api/libraries", json={
"name": "DDF", "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": "Die drei ??? und die Toteninsel",
"series": "Die drei ???", "volume": 100,
}).json()["id"]
base = client.downloads / "Folgen" / "100 - Toteninsel"
for part in ("Teil 1", "Teil 2", "Teil 3"):
d = base / part
d.mkdir(parents=True)
for i in (1, 2):
(d / f"{i:02d}.mp3").write_bytes(b"")
(base / "Cover").mkdir()
(base / "Cover" / "front.jpg").write_bytes(b"")
# "Teil N" folders are disc folders, so the scan already yields one entry
items = client.get("/api/import/scan").json()["items"]
assert len(items) == 1 and len(items[0]["files"]) == 6
# a split scan yields the parts separately - that is what the merge button
# in the UI stitches back together
parts = client.get("/api/import/scan", params={"split_dirs": True}).json()["items"]
assert len(parts) == 6
merged_files = [f for p in parts for f in p["files"]]
res = client.post("/api/import", json={"items": [{
"path": str(base), "is_dir": True, "files": merged_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 drei ??? und die Toteninsel - Part 0{i}.mp3" for i in range(1, 7)]
# emptied part folders are gone, the cover folder survives
assert not (base / "Teil 1").exists()
assert (base / "Cover" / "front.jpg").exists()
def test_import_rejects_files_outside_download_dir(client):
lib_id = client.post("/api/libraries", json={
"name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"),
}).json()["id"]
req_id = client.post("/api/requests",
json={"library_id": lib_id, "title": "X"}).json()["id"]
outside = client.tmp_path / "elsewhere"
outside.mkdir()
(outside / "a.mp3").write_bytes(b"")
folder = client.downloads / "ok"
folder.mkdir()
(folder / "b.mp3").write_bytes(b"")
res = client.post("/api/import", json={"items": [{
"path": str(folder), "is_dir": True,
"files": [str(folder / "b.mp3"), str(outside / "a.mp3")], "request_id": req_id,
}]}).json()["results"][0]
assert not res["ok"] and "outside" in res["error"]
assert (outside / "a.mp3").exists()
def test_merge_at_download_root_keeps_the_download_dir(client):
"""Merging entries that sit directly in the download dir makes the item path
the download dir itself - which must never be removed."""
lib_id = client.post("/api/libraries", json={
"name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"),
}).json()["id"]
req_id = client.post("/api/requests",
json={"library_id": lib_id, "title": "Toteninsel"}).json()["id"]
files = []
for part in ("Teil 1", "Teil 2"):
d = client.downloads / part
d.mkdir()
(d / "a.mp3").write_bytes(b"")
(d / "b.mp3").write_bytes(b"")
files += [str(d / "a.mp3"), str(d / "b.mp3")]
res = client.post("/api/import", json={"items": [{
"path": str(client.downloads), "is_dir": True, "files": files,
"request_id": req_id,
}]}).json()["results"][0]
assert res["ok"], res
assert client.downloads.is_dir()
assert not (client.downloads / "Teil 1").exists()
+4 -2
View File
@@ -45,8 +45,10 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
if not req or req.status != "missing":
results.append({"path": item.path, "ok": False, "error": "request not found or not missing"})
continue
src = Path(item.path).resolve()
if download_root not in src.parents and src != download_root:
# every source path must stay inside the download dir - merged items
# carry files from several folders, so check them all
paths = [Path(p).resolve() for p in [item.path, *item.files]]
if any(download_root not in p.parents and p != download_root for p in paths):
results.append({"path": item.path, "ok": False, "error": "path outside download dir"})
continue
try:
+13 -4
View File
@@ -1,6 +1,7 @@
import shutil
from pathlib import Path
from .. import config
from ..naming import render_template, sanitize
from . import tagger
@@ -21,11 +22,19 @@ 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 the source dir if nothing meaningful is left
# 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)
leftovers = [p for p in src_dir.rglob("*") if p.is_file()]
if not leftovers:
shutil.rmtree(src_dir, ignore_errors=True)
download_root = Path(config.DOWNLOAD_DIR).resolve()
candidates = {src_dir} | {p.parent for p in paths}
for d in sorted(candidates, key=lambda p: len(p.parts), reverse=True):
if d != src_dir and src_dir not in d.parents:
continue # never touch anything outside the item
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)
src = paths[0]