diff --git a/static/app.js b/static/app.js index 2123426..6ceb1a4 100644 --- a/static/app.js +++ b/static/app.js @@ -159,6 +159,7 @@ $("#search-form").addEventListener("submit", async (e) => { ${esc(r.title)} ${esc(r.authors)} ${r.narrator ? `🎙 ${esc(r.narrator)}` : ""} + ${r.series && r.media_type ? `📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}` : ""} ${r.year ?? ""} ${r.external_id ? "· " + esc(r.external_id) : ""} ${type === "comic" ? `` : ""}
@@ -184,7 +185,7 @@ $("#search-form").addEventListener("submit", async (e) => { title: r.title, authors: r.authors, narrator: r.narrator || "", external_id: r.external_id, year: r.year, series: r.series, cover_url: r.cover_url, - volume: volInput && volInput.value ? parseInt(volInput.value) : null, + volume: volInput && volInput.value ? parseInt(volInput.value) : (r.volume ?? null), }), }); setLastLib(type, libId); @@ -368,28 +369,50 @@ function openDetail(id, status) { img.hidden = !r.cover_url; $("#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-dialog").showModal(); } +async function saveDetail(form) { + await api("/api/requests/" + detailRequest.id, { + method: "PUT", + body: JSON.stringify({ + library_id: parseInt(form.library_id.value), + title: form.title.value, + authors: form.authors.value, + narrator: form.narrator.value, + series: form.series.value, + volume: form.volume.value ? parseInt(form.volume.value) : null, + year: form.year.value ? parseInt(form.year.value) : null, + external_id: form.external_id.value, + cover_url: detailRequest.cover_url || "", + }), + }); +} + +$("#detail-retag").addEventListener("click", async () => { + if (!detailRequest) return; + const btn = $("#detail-retag"); + btn.disabled = true; + btn.innerHTML = ' Tagge…'; + try { + await saveDetail($("#detail-form")); + const res = await api(`/api/requests/${detailRequest.id}/retag`, { method: "POST" }); + toast(`${res.files} Datei(en) neu getaggt`); + $("#detail-dialog").close(); + loadRequests(detailRequest.status); + } catch (err) { toast(err.message, true); } + finally { + btn.disabled = false; + btn.textContent = "Speichern & neu taggen"; + } +}); + $("#detail-form").addEventListener("submit", async (e) => { if (e.submitter && e.submitter.value === "cancel") return; if (!detailRequest) return; - const form = e.target; try { - await api("/api/requests/" + detailRequest.id, { - method: "PUT", - body: JSON.stringify({ - library_id: parseInt(form.library_id.value), - title: form.title.value, - authors: form.authors.value, - narrator: form.narrator.value, - series: form.series.value, - volume: form.volume.value ? parseInt(form.volume.value) : null, - year: form.year.value ? parseInt(form.year.value) : null, - external_id: form.external_id.value, - cover_url: detailRequest.cover_url || "", - }), - }); + await saveDetail(e.target); toast("Anfrage gespeichert"); loadRequests(detailRequest.status); } catch (err) { toast(err.message, true); } @@ -603,6 +626,7 @@ async function runQuickSearch() { ${esc(r.title)} ${esc(r.authors)} ${r.narrator ? `🎙 ${esc(r.narrator)}` : ""} + ${r.series && r.media_type ? `📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}` : ""} ${r.year ?? ""}
@@ -634,7 +658,7 @@ async function pickQuickResult(r) { body: JSON.stringify({ library_id: parseInt(libId), title: r.title, authors: r.authors, narrator: r.narrator || "", - external_id: r.external_id, + external_id: r.external_id, volume: r.volume ?? null, year: r.year, series: r.series, cover_url: r.cover_url, }), }); diff --git a/static/index.html b/static/index.html index 9991ed7..466e6c4 100644 --- a/static/index.html +++ b/static/index.html @@ -246,6 +246,9 @@

+
diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 9082f1c..7aec951 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -225,3 +225,42 @@ def test_scan_rel_dir_and_request_update(client): assert updated["narrator"] == "Rufus Beck" assert updated["series"] == "HP" assert client.get("/api/requests", params={"status": "missing"}).json()[0]["narrator"] == "Rufus Beck" + + +def test_retag_after_update(client): + from mutagen.id3 import ID3 + + root = client.tmp_path / "library" / "retag" + lib_id = client.post("/api/libraries", json={ + "name": "Retag", "media_type": "audiobook", "root_path": str(root), + }).json()["id"] + req_id = client.post("/api/requests", json={ + "library_id": lib_id, "title": "Der Ickabog", "authors": "J.K. Rowling", + }).json()["id"] + + book_dir = client.downloads / "Der Ickabog" + book_dir.mkdir() + for i in range(2): + (book_dir / f"t{i}.mp3").write_bytes(b"") + items = client.get("/api/import/scan").json()["items"] + client.post("/api/import", json={"items": [{ + "path": items[0]["path"], "is_dir": True, + "files": items[0]["files"], "request_id": req_id, + }]}) + + # no series yet -> tag absent + dest = root / "J.K. Rowling" / "Der Ickabog" + first = sorted(dest.iterdir())[0] + assert "TXXX:SERIES" not in ID3(first) + + # add series + narrator, then retag + client.put(f"/api/requests/{req_id}", json={ + "library_id": lib_id, "title": "Der Ickabog", "authors": "J.K. Rowling", + "narrator": "Ben Becker", "series": "Rowling Kinderbücher", "volume": 2, + }) + res = client.post(f"/api/requests/{req_id}/retag").json() + assert res == {"ok": True, "files": 2} + tags = ID3(first) + assert str(tags["TXXX:SERIES"]) == "Rowling Kinderbücher" + assert str(tags["TXXX:SERIES-PART"]) == "2" + assert str(tags["TCOM"]) == "Ben Becker" diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py index a2690bf..4518ed3 100644 --- a/wordarr/api/requests.py +++ b/wordarr/api/requests.py @@ -1,9 +1,13 @@ +from pathlib import Path + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session +from .. import config from ..db import BookRequest, Library, get_session +from ..importer import tagger router = APIRouter(prefix="/api/requests", tags=["requests"]) @@ -120,6 +124,34 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends( return _out(req) +@router.post("/{request_id}/retag") +def retag_request(request_id: int, session: Session = Depends(get_session)): + """Rewrite the audio tags of an already imported request from its current + metadata (e.g. after adding series/volume/narrator later).""" + req = session.get(BookRequest, request_id) + if not req: + raise HTTPException(404, "request not found") + if req.media_type != "audiobook": + raise HTTPException(400, "retag is only supported for audiobooks") + if req.status != "imported" or not req.imported_path: + raise HTTPException(400, "request is not imported yet") + path = Path(req.imported_path) + if path.is_dir(): + files = sorted( + f for f in path.iterdir() + if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS + ) + if not files: + raise HTTPException(404, f"no audio files in {path}") + for i, f in enumerate(files, 1): + tagger.tag_audio(f, req, track=i, total=len(files)) + return {"ok": True, "files": len(files)} + if path.is_file(): + tagger.tag_audio(path, req) + return {"ok": True, "files": 1} + raise HTTPException(404, f"imported path no longer exists: {path}") + + @router.delete("/{request_id}") def delete_request(request_id: int, session: Session = Depends(get_session)): req = session.get(BookRequest, request_id) diff --git a/wordarr/metadata/audible.py b/wordarr/metadata/audible.py index fa43a59..4d742a1 100644 --- a/wordarr/metadata/audible.py +++ b/wordarr/metadata/audible.py @@ -13,7 +13,7 @@ async def _search_region(client: httpx.AsyncClient, tld: str, query: str) -> lis params = { "keywords": query, "num_results": 10, - "response_groups": "media,contributors,product_desc,product_attrs", + "response_groups": "media,contributors,product_desc,product_attrs,series", "products_sort_by": "Relevance", } try: @@ -40,12 +40,21 @@ async def search(query: str) -> list[MetadataResult]: seen_asins.add(asin) images = p.get("product_images") or {} release = p.get("release_date") or "" + series_list = p.get("series") or [] + series = series_list[0].get("title", "") if series_list else "" + seq = series_list[0].get("sequence", "") if series_list else "" + try: + volume = int(float(seq)) + except (TypeError, ValueError): + volume = None results.append( MetadataResult( media_type="audiobook", title=p.get("title", ""), authors=", ".join(a.get("name", "") for a in p.get("authors") or []), narrator=", ".join(n.get("name", "") for n in p.get("narrators") or []), + series=series, + volume=volume, external_id=asin, year=int(release[:4]) if release[:4].isdigit() else None, cover_url=next(iter(images.values()), ""),