fix(metadata): get series from api

This commit is contained in:
Steppenstreuner
2026-08-28 17:58:37 +02:00
parent 435382d42a
commit 1db06aed25
5 changed files with 125 additions and 18 deletions
+31 -7
View File
@@ -159,6 +159,7 @@ $("#search-form").addEventListener("submit", async (e) => {
<strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
<span class="muted">${r.year ?? ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
<div class="row">
@@ -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,14 +369,11 @@ 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();
}
$("#detail-form").addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
if (!detailRequest) return;
const form = e.target;
try {
async function saveDetail(form) {
await api("/api/requests/" + detailRequest.id, {
method: "PUT",
body: JSON.stringify({
@@ -390,6 +388,31 @@ $("#detail-form").addEventListener("submit", async (e) => {
cover_url: detailRequest.cover_url || "",
}),
});
}
$("#detail-retag").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-retag");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> 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;
try {
await saveDetail(e.target);
toast("Anfrage gespeichert");
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
@@ -603,6 +626,7 @@ async function runQuickSearch() {
<strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
<span class="muted">${r.year ?? ""}</span>
<div class="row"><button data-pick="${j}">Anfragen &amp; verbinden</button></div>
</div>
@@ -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,
}),
});
+3
View File
@@ -246,6 +246,9 @@
<p class="muted mono" id="detail-path"></p>
<div class="row">
<button value="cancel" class="secondary">Abbrechen</button>
<button type="button" id="detail-retag" class="secondary" hidden>
Speichern &amp; neu taggen
</button>
<button value="ok" id="detail-save">Speichern</button>
</div>
</form>
+39
View File
@@ -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"
+32
View File
@@ -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)
+10 -1
View File
@@ -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()), ""),