add(core): re-apply the naming scheme to imported titles
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**.
|
||||
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
@@ -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
@@ -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 & neu taggen
|
||||
</button>
|
||||
<button type="button" id="detail-relocate" class="secondary" hidden
|
||||
title="Ordner und Dateinamen nach dem Schema der Library neu anlegen">
|
||||
Speichern & neu ablegen
|
||||
</button>
|
||||
<button value="ok" id="detail-save">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user