add(import): merge multi-part entries into one audiobook
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**.
|
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.
|
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`).
|
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)
|
## Setup (Docker)
|
||||||
|
|
||||||
|
|||||||
@@ -711,12 +711,14 @@ function renderImportTable() {
|
|||||||
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
||||||
<td class="mono">
|
<td class="mono">
|
||||||
${esc(item.name)}${item.is_dir ? " 📁" : ""}
|
${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>` : ""}
|
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
|
||||||
</td>
|
</td>
|
||||||
<td>${esc(item.media_type)}</td>
|
<td>${esc(item.media_type)}</td>
|
||||||
<td class="row">
|
<td class="row">
|
||||||
<select data-select="${i}"><option value="">— überspringen —</option>${opts}</select>
|
<select data-select="${i}"><option value="">— überspringen —</option>${opts}</select>
|
||||||
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
|
<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>
|
</td>
|
||||||
${scoreCell(item)}
|
${scoreCell(item)}
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -728,6 +730,9 @@ function renderImportTable() {
|
|||||||
tbody.querySelectorAll("[data-quick]").forEach((b) =>
|
tbody.querySelectorAll("[data-quick]").forEach((b) =>
|
||||||
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
|
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) =>
|
tbody.querySelectorAll("[data-check]").forEach((c) =>
|
||||||
c.addEventListener("change", () => {
|
c.addEventListener("change", () => {
|
||||||
scanItems[c.dataset.check].checked = c.checked;
|
scanItems[c.dataset.check].checked = c.checked;
|
||||||
@@ -783,6 +788,62 @@ $("#import-deselect-all").addEventListener("click", () => {
|
|||||||
renderImportTable();
|
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 ----
|
// ---- quick request from a scanned file ----
|
||||||
let quickItemIndex = null;
|
let quickItemIndex = null;
|
||||||
let quickEpisode = null;
|
let quickEpisode = null;
|
||||||
|
|||||||
@@ -162,6 +162,9 @@
|
|||||||
<button id="import-deselect-all" class="secondary">
|
<button id="import-deselect-all" class="secondary">
|
||||||
Alle abwählen
|
Alle abwählen
|
||||||
</button>
|
</button>
|
||||||
|
<button id="import-merge" class="secondary" title="Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren">
|
||||||
|
Ausgewählte zusammenfassen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table id="import-table" hidden>
|
<table id="import-table" hidden>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ button:disabled { opacity: 0.6; cursor: default; }
|
|||||||
.muted { color: var(--muted); font-size: 0.88rem; }
|
.muted { color: var(--muted); font-size: 0.88rem; }
|
||||||
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
|
.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; }
|
.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 modes (Missing/Importiert) */
|
||||||
.view-toggle { display: flex; gap: 0; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; margin-left: auto; }
|
.view-toggle { display: flex; gap: 0; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; margin-left: auto; }
|
||||||
|
|||||||
@@ -414,3 +414,93 @@ def test_bulk_items_series_then_scan_matches_by_episode_number(client):
|
|||||||
n = int(item["name"][:3])
|
n = int(item["name"][:3])
|
||||||
assert item["suggested_request_id"] == by_volume[n], item
|
assert item["suggested_request_id"] == by_volume[n], item
|
||||||
assert item["score"] >= 90
|
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()
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
|||||||
if not req or req.status != "missing":
|
if not req or req.status != "missing":
|
||||||
results.append({"path": item.path, "ok": False, "error": "request not found or not missing"})
|
results.append({"path": item.path, "ok": False, "error": "request not found or not missing"})
|
||||||
continue
|
continue
|
||||||
src = Path(item.path).resolve()
|
# every source path must stay inside the download dir - merged items
|
||||||
if download_root not in src.parents and src != download_root:
|
# 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"})
|
results.append({"path": item.path, "ok": False, "error": "path outside download dir"})
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import config
|
||||||
from ..naming import render_template, sanitize
|
from ..naming import render_template, sanitize
|
||||||
from . import tagger
|
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)
|
shutil.move(str(src), dest)
|
||||||
if request.media_type == "audiobook":
|
if request.media_type == "audiobook":
|
||||||
tagger.tag_audio(dest, request, track=i, total=len(paths))
|
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)
|
src_dir = Path(item_path)
|
||||||
leftovers = [p for p in src_dir.rglob("*") if p.is_file()]
|
download_root = Path(config.DOWNLOAD_DIR).resolve()
|
||||||
if not leftovers:
|
candidates = {src_dir} | {p.parent for p in paths}
|
||||||
shutil.rmtree(src_dir, ignore_errors=True)
|
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)
|
return str(folder)
|
||||||
|
|
||||||
src = paths[0]
|
src = paths[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user