add(metadata): filter audible results by library language

This commit is contained in:
Steppenstreuner
2026-08-29 05:05:00 +02:00
parent 714a435e2d
commit e3701ceb43
11 changed files with 99 additions and 12 deletions
+4
View File
@@ -23,6 +23,10 @@ docker compose up -d --build
| `WORDARR_DOWNLOAD_DIR` | `/mnt/downloads` | Gescannter Download-Ordner | | `WORDARR_DOWNLOAD_DIR` | `/mnt/downloads` | Gescannter Download-Ordner |
| `WORDARR_CONFIG_DIR` | `/config` | Ablage der SQLite-DB | | `WORDARR_CONFIG_DIR` | `/config` | Ablage der SQLite-DB |
## 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.
## Namensschemata ## Namensschemata
Pro Library konfigurierbar (Tab *Libraries*), Platzhalter: `{Author}` `{Authors}` `{Title}` `{Year}` `{Series}` `{Volume}`. Pro Library konfigurierbar (Tab *Libraries*), Platzhalter: `{Author}` `{Authors}` `{Title}` `{Year}` `{Series}` `{Volume}`.
+14 -3
View File
@@ -2,6 +2,7 @@ const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel); const $$ = (sel) => document.querySelectorAll(sel);
let libraries = []; let libraries = [];
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
async function api(path, opts = {}) { async function api(path, opts = {}) {
const resp = await fetch(path, { const resp = await fetch(path, {
@@ -93,6 +94,7 @@ async function loadLibraries() {
(l) => `<tr> (l) => `<tr>
<td>${esc(l.name)}</td><td>${esc(l.media_type)}</td><td>${esc(l.root_path)}</td> <td>${esc(l.name)}</td><td>${esc(l.media_type)}</td><td>${esc(l.root_path)}</td>
<td class="mono">${esc(l.folder_template)}</td><td class="mono">${esc(l.file_template)}</td> <td class="mono">${esc(l.folder_template)}</td><td class="mono">${esc(l.file_template)}</td>
<td>${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"}</td>
<td class="row"> <td class="row">
<button class="secondary" data-edit-lib="${l.id}">Bearbeiten</button> <button class="secondary" data-edit-lib="${l.id}">Bearbeiten</button>
<button class="danger" data-del-lib="${l.id}">Löschen</button> <button class="danger" data-del-lib="${l.id}">Löschen</button>
@@ -127,6 +129,7 @@ function startLibraryEdit(id) {
form.root_path.value = lib.root_path; form.root_path.value = lib.root_path;
form.folder_template.value = lib.folder_template; form.folder_template.value = lib.folder_template;
form.file_template.value = lib.file_template; form.file_template.value = lib.file_template;
form.language.value = lib.language || "";
form.dataset.editId = lib.id; form.dataset.editId = lib.id;
$("#lib-form-title").textContent = `Library „${lib.name}“ bearbeiten`; $("#lib-form-title").textContent = `Library „${lib.name}“ bearbeiten`;
$("#lib-form-submit").textContent = "Speichern"; $("#lib-form-submit").textContent = "Speichern";
@@ -199,7 +202,10 @@ $("#search-form").addEventListener("submit", async (e) => {
searchBtn.disabled = true; searchBtn.disabled = true;
searchBtn.innerHTML = '<span class="spinner"></span> Suche…'; searchBtn.innerHTML = '<span class="spinner"></span> Suche…';
try { try {
const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`); const language = $("#search-language").value;
const results = await api(
`/api/search?media_type=${type}&q=${encodeURIComponent(q)}&language=${language}`
);
if (!results.length) { if (!results.length) {
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[type]}</p>`; box.innerHTML = `<p class='muted'>${EMPTY_HINTS[type]}</p>`;
return; return;
@@ -214,7 +220,7 @@ $("#search-form").addEventListener("submit", async (e) => {
<span>${esc(r.authors)}</span> <span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</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>` : ""} ${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> <span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""} ${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
<div class="row"> <div class="row">
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select> <select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
@@ -955,8 +961,12 @@ async function runQuickSearch() {
const box = $("#quick-results"); const box = $("#quick-results");
box.innerHTML = "<p class='muted'>Suche läuft…</p>"; box.innerHTML = "<p class='muted'>Suche läuft…</p>";
try { try {
// the target library decides the language: an "english" library should not
// offer the German edition of the same book
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
const results = await api( const results = await api(
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` `/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` +
`&language=${lib?.language || ""}`
); );
if (!results.length) { if (!results.length) {
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`; box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
@@ -970,6 +980,7 @@ async function runQuickSearch() {
r.narrator ? "🎙 " + r.narrator : "", r.narrator ? "🎙 " + r.narrator : "",
r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "", r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "",
r.year ?? "", r.year ?? "",
LANGUAGE_NAMES[r.language] || r.language,
].filter(Boolean).map(esc).join(" · "); ].filter(Boolean).map(esc).join(" · ");
return `<div class="card"> return `<div class="card">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'} ${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
+11
View File
@@ -30,6 +30,11 @@
<option value="audiobook">Audiobook</option> <option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option> <option value="comic">Comic/Manga</option>
</select> </select>
<select id="search-language" title="Nur Audiobooks: Sprache der Ausgabe">
<option value="">Alle Sprachen</option>
<option value="german">Deutsch</option>
<option value="english">Englisch</option>
</select>
<input id="search-q" placeholder="Titel, Autor oder ISBN…" required /> <input id="search-q" placeholder="Titel, Autor oder ISBN…" required />
<button type="submit" id="search-btn">Suchen</button> <button type="submit" id="search-btn">Suchen</button>
<button type="button" id="manual-btn" class="secondary"> <button type="button" id="manual-btn" class="secondary">
@@ -237,6 +242,7 @@
<th>Pfad</th> <th>Pfad</th>
<th>Ordner-Schema</th> <th>Ordner-Schema</th>
<th>Datei-Schema</th> <th>Datei-Schema</th>
<th>Sprache</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -267,6 +273,11 @@
name="file_template" name="file_template"
placeholder="Datei-Schema (optional)" placeholder="Datei-Schema (optional)"
class="wide" /> class="wide" />
<select name="language" title="Sprache der Ausgaben in dieser Library — filtert die Audible-Suche">
<option value="">Sprache: egal</option>
<option value="german">Deutsch</option>
<option value="english">Englisch</option>
</select>
<button type="submit" id="lib-form-submit">Anlegen</button> <button type="submit" id="lib-form-submit">Anlegen</button>
<button type="button" id="lib-form-cancel" class="secondary" hidden> <button type="button" id="lib-form-cancel" class="secondary" hidden>
Abbrechen Abbrechen
+40
View File
@@ -647,3 +647,43 @@ def test_append_without_the_flag_is_still_refused(client):
assert not res["ok"] assert not res["ok"]
assert "bereits importiert" in res["error"] assert "bereits importiert" in res["error"]
assert (second / "c.mp3").exists() assert (second / "c.mp3").exists()
def test_library_language_round_trip(client):
lib = client.post("/api/libraries", json={
"name": "english", "media_type": "audiobook",
"root_path": str(client.tmp_path / "en"), "language": "english",
}).json()
assert lib["language"] == "english"
assert client.get("/api/libraries").json()[0]["language"] == "english"
# libraries without a language keep working and stay unrestricted
other = client.post("/api/libraries", json={
"name": "adults", "media_type": "audiobook",
"root_path": str(client.tmp_path / "de"),
}).json()
assert other["language"] == ""
updated = client.put(f"/api/libraries/{other['id']}", json={
"name": "adults", "media_type": "audiobook",
"root_path": str(client.tmp_path / "de"), "language": "german",
}).json()
assert updated["language"] == "german"
def test_search_passes_language_to_the_provider(client, monkeypatch):
from wordarr.metadata.base import MetadataResult
seen = {}
async def fake(query, language=""):
seen["query"], seen["language"] = query, language
return [MetadataResult(media_type="audiobook", title="A Game of Thrones",
language="english")]
monkeypatch.setitem(
__import__("wordarr.metadata", fromlist=["PROVIDERS"]).PROVIDERS, "audiobook", fake)
res = client.get("/api/search", params={
"media_type": "audiobook", "q": "Game of Thrones", "language": "english",
}).json()
assert seen == {"query": "Game of Thrones", "language": "english"}
assert res[0]["language"] == "english"
+3
View File
@@ -15,6 +15,7 @@ class LibraryIn(BaseModel):
root_path: str root_path: str
folder_template: str | None = None folder_template: str | None = None
file_template: str | None = None file_template: str | None = None
language: str = "" # "german" / "english" - filters the Audible search
class LibraryOut(LibraryIn): class LibraryOut(LibraryIn):
@@ -44,6 +45,7 @@ def create_library(data: LibraryIn, session: Session = Depends(get_session)):
root_path=data.root_path, root_path=data.root_path,
folder_template=data.folder_template or defaults["folder"], folder_template=data.folder_template or defaults["folder"],
file_template=data.file_template or defaults["file"], file_template=data.file_template or defaults["file"],
language=data.language,
) )
session.add(lib) session.add(lib)
session.commit() session.commit()
@@ -62,6 +64,7 @@ def update_library(library_id: int, data: LibraryIn, session: Session = Depends(
lib.root_path = data.root_path lib.root_path = data.root_path
lib.folder_template = data.folder_template or defaults["folder"] lib.folder_template = data.folder_template or defaults["folder"]
lib.file_template = data.file_template or defaults["file"] lib.file_template = data.file_template or defaults["file"]
lib.language = data.language
session.commit() session.commit()
return lib return lib
+2 -2
View File
@@ -7,12 +7,12 @@ router = APIRouter(prefix="/api/search", tags=["search"])
@router.get("", response_model=list[MetadataResult]) @router.get("", response_model=list[MetadataResult])
async def search(media_type: str, q: str): async def search(media_type: str, q: str, language: str = ""):
provider = PROVIDERS.get(media_type) provider = PROVIDERS.get(media_type)
if not provider: if not provider:
raise HTTPException(400, f"invalid media_type: {media_type}") raise HTTPException(400, f"invalid media_type: {media_type}")
try: try:
return await provider(q) return await provider(q, language)
except Exception as exc: except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}") raise HTTPException(502, f"metadata provider error: {exc}")
+10 -3
View File
@@ -19,6 +19,9 @@ class Library(Base):
root_path: Mapped[str] = mapped_column(String) root_path: Mapped[str] = mapped_column(String)
folder_template: Mapped[str] = mapped_column(String) folder_template: Mapped[str] = mapped_column(String)
file_template: Mapped[str] = mapped_column(String) file_template: Mapped[str] = mapped_column(String)
# audible language of this library's editions ("english", "german", …).
# empty means no restriction.
language: Mapped[str] = mapped_column(String, default="")
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library") requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
@@ -58,9 +61,13 @@ def init_db(db_path=None):
Base.metadata.create_all(_engine) Base.metadata.create_all(_engine)
# lightweight migration: create_all doesn't add new columns to existing tables # lightweight migration: create_all doesn't add new columns to existing tables
with _engine.begin() as conn: with _engine.begin() as conn:
cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(requests)")] def add_column(table: str, column: str, ddl: str) -> None:
if "narrator" not in cols: cols = [row[1] for row in conn.exec_driver_sql(f"PRAGMA table_info({table})")]
conn.exec_driver_sql("ALTER TABLE requests ADD COLUMN narrator VARCHAR NOT NULL DEFAULT ''") if column not in cols:
conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {ddl}")
add_column("requests", "narrator", "narrator VARCHAR NOT NULL DEFAULT ''")
add_column("libraries", "language", "language VARCHAR NOT NULL DEFAULT ''")
SessionLocal = sessionmaker(bind=_engine, expire_on_commit=False) SessionLocal = sessionmaker(bind=_engine, expire_on_commit=False)
return _engine return _engine
+2 -1
View File
@@ -17,7 +17,8 @@ query ($search: String) {
""" """
async def search(query: str) -> list[MetadataResult]: async def search(query: str, language: str = "") -> list[MetadataResult]:
# language is an Audible concept; AniList results carry no usable language tag
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post( resp = await client.post(
"https://graphql.anilist.co", "https://graphql.anilist.co",
+10 -2
View File
@@ -7,6 +7,9 @@ from .base import MetadataResult
# Comma-separated marketplace TLDs, first entries rank first in the results. # Comma-separated marketplace TLDs, first entries rank first in the results.
REGIONS = [r.strip() for r in os.environ.get("WORDARR_AUDIBLE_REGIONS", "de,com").split(",")] REGIONS = [r.strip() for r in os.environ.get("WORDARR_AUDIBLE_REGIONS", "de,com").split(",")]
# a marketplace ranks its own language first, so searching for an English
# edition works much better on .com than on .de - and vice versa
LANGUAGE_REGIONS = {"english": ["com", "co.uk"], "german": ["de"]}
RESPONSE_GROUPS = "media,contributors,product_desc,product_attrs,series" RESPONSE_GROUPS = "media,contributors,product_desc,product_attrs,series"
PAGE_SIZE = 50 PAGE_SIZE = 50
@@ -56,6 +59,7 @@ def _to_result(p: dict) -> MetadataResult:
title=p.get("title", ""), title=p.get("title", ""),
authors=", ".join(a.get("name", "") for a in p.get("authors") or []), authors=", ".join(a.get("name", "") for a in p.get("authors") or []),
narrator=", ".join(n.get("name", "") for n in p.get("narrators") or []), narrator=", ".join(n.get("name", "") for n in p.get("narrators") or []),
language=(p.get("language") or "").lower(),
series=first.get("title", ""), series=first.get("title", ""),
series_id=first.get("asin", ""), series_id=first.get("asin", ""),
volume=volume, volume=volume,
@@ -65,10 +69,11 @@ def _to_result(p: dict) -> MetadataResult:
) )
async def search(query: str) -> list[MetadataResult]: async def search(query: str, language: str = "") -> list[MetadataResult]:
regions = LANGUAGE_REGIONS.get(language, REGIONS)
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
region_results = await asyncio.gather( region_results = await asyncio.gather(
*(_query(client, tld, query) for tld in REGIONS) *(_query(client, tld, query) for tld in regions)
) )
results = [] results = []
@@ -80,6 +85,9 @@ async def search(query: str) -> list[MetadataResult]:
continue continue
seen_asins.add(asin) seen_asins.add(asin)
results.append(_to_result(p)) results.append(_to_result(p))
if language:
# keep entries whose language is unknown, drop the ones we know differ
results = [r for r in results if r.language in ("", language)]
return results return results
+1
View File
@@ -8,6 +8,7 @@ class MetadataResult(BaseModel):
narrator: str = "" narrator: str = ""
external_id: str = "" external_id: str = ""
year: int | None = None year: int | None = None
language: str = "" # audible: "english", "german", …
series: str = "" series: str = ""
series_id: str = "" series_id: str = ""
volume: int | None = None volume: int | None = None
+2 -1
View File
@@ -7,7 +7,8 @@ from .base import MetadataResult
_ISBN = re.compile(r"^(97[89])?\d{9}[\dXx]$") _ISBN = re.compile(r"^(97[89])?\d{9}[\dXx]$")
async def search(query: str) -> list[MetadataResult]: async def search(query: str, language: str = "") -> list[MetadataResult]:
# language is an Audible concept; Open Library results carry no usable language tag
query = query.strip() query = query.strip()
isbn = query.replace("-", "").replace(" ", "") isbn = query.replace("-", "").replace(" ", "")
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client: