add(core): re-apply the naming scheme to imported titles

This commit is contained in:
Steppenstreuner
2026-08-29 06:11:35 +02:00
parent 87b139e236
commit 3e28ad5f94
8 changed files with 345 additions and 9 deletions
+8 -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. 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.
+83 -6
View File
@@ -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 = '<span class="spinner"></span> 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() {
<td class="mono">
${esc(item.name)}${item.is_dir ? " 📁" : ""}
${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""}
${!item.parts && item.maybe_separate
? `<span class="tag warn" title="Die ${item.files.length} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen">${item.files.length} Titel?</span>`
: ""}
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
</td>
<td>${esc(item.media_type)}</td>
@@ -734,7 +758,9 @@ function renderImportTable() {
${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>` : ""}
${item.parts || (item.is_dir && item.files.length > 1)
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
: ""}
</td>
${scoreCell(item)}
</tr>`;
@@ -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) || "<option value=''>— keine Library —</option>";
@@ -988,6 +1039,7 @@ async function runQuickSearch() {
<strong>${esc(r.title)}</strong>
<span class="muted">${meta}</span>
</div>
<button data-edit="${j}" class="secondary" title="Felder vor dem Anlegen anpassen (z.B. Serie)">✎</button>
<button data-pick="${j}" title="Anfrage anlegen und mit diesem Eintrag verbinden">Verbinden</button>
</div>`;
})
@@ -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,
});
+9 -1
View File
@@ -213,9 +213,13 @@
</div>
<div id="quick-foot">
<details id="quick-manual">
<summary>Nicht bei Audible? Manuell anlegen</summary>
<summary id="quick-manual-summary">
Nicht bei Audible? Manuell anlegen
</summary>
<p class="muted" id="quick-based-on" hidden></p>
<div class="quick-manual-fields">
<label>Titel <input id="quick-m-title" /></label>
<label>Autor <input id="quick-m-authors" placeholder="optional" /></label>
<label>Serie <input id="quick-m-series" placeholder="optional" /></label>
<label class="narrow"
>Folge/Band <input id="quick-m-volume" type="number" min="0" /></label>
@@ -316,6 +320,10 @@
<button type="button" id="detail-retag" class="secondary" hidden>
Speichern &amp; neu taggen
</button>
<button type="button" id="detail-relocate" class="secondary" hidden
title="Ordner und Dateinamen nach dem Schema der Library neu anlegen">
Speichern &amp; neu ablegen
</button>
<button value="ok" id="detail-save">Speichern</button>
</div>
</form>
+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.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) */
+133
View File
@@ -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()
+22 -1
View File
@@ -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)
+65
View File
@@ -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
+24
View File
@@ -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)):