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**.
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
View File
@@ -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;
$("#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,
// 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),
}));
if (!items.length) { toast("Nichts ausgewählt", true); return; }
}
// 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 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 = [];
+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 / "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
View File
@@ -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
View File
@@ -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
View File
@@ -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))