fix(metadata): get series from api
This commit is contained in:
+41
-17
@@ -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,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 = '<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;
|
||||
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() {
|
||||
<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 & 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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 & neu taggen
|
||||
</button>
|
||||
<button value="ok" id="detail-save">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()), ""),
|
||||
|
||||
Reference in New Issue
Block a user