diff --git a/README.md b/README.md
index e5f28c2..32f189f 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. 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)
diff --git a/static/app.js b/static/app.js
index da169ba..fcfdf6e 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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 = ' 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 = "") =>
+ ``;
const opts = missingReqs
.filter((r) => r.media_type === item.media_type)
- .map(
- (r) =>
- ``
- )
+ .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 `
@@ -716,7 +724,9 @@ function renderImportTable() {
${esc(item.media_type)}
-
+
${item.parts ? `` : ""}
@@ -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 = [];
diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py
index 98fc5df..7c5f01f 100644
--- a/tests/test_import_flow.py
+++ b/tests/test_import_flow.py
@@ -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()
diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py
index 33c324b..68a5435 100644
--- a/wordarr/api/imports.py
+++ b/wordarr/api/imports.py
@@ -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
diff --git a/wordarr/importer/mover.py b/wordarr/importer/mover.py
index c553c59..4887004 100644
--- a/wordarr/importer/mover.py
+++ b/wordarr/importer/mover.py
@@ -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)
diff --git a/wordarr/importer/scanner.py b/wordarr/importer/scanner.py
index d8d3dac..2c4a4e0 100644
--- a/wordarr/importer/scanner.py
+++ b/wordarr/importer/scanner.py
@@ -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))