add(metadata): filter audible results by library language
This commit is contained in:
@@ -23,6 +23,10 @@ docker compose up -d --build
|
||||
| `WORDARR_DOWNLOAD_DIR` | `/mnt/downloads` | Gescannter Download-Ordner |
|
||||
| `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
|
||||
|
||||
Pro Library konfigurierbar (Tab *Libraries*), Platzhalter: `{Author}` `{Authors}` `{Title}` `{Year}` `{Series}` `{Volume}`.
|
||||
|
||||
+14
-3
@@ -2,6 +2,7 @@ const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
let libraries = [];
|
||||
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const resp = await fetch(path, {
|
||||
@@ -93,6 +94,7 @@ async function loadLibraries() {
|
||||
(l) => `<tr>
|
||||
<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>${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"}</td>
|
||||
<td class="row">
|
||||
<button class="secondary" data-edit-lib="${l.id}">Bearbeiten</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.folder_template.value = lib.folder_template;
|
||||
form.file_template.value = lib.file_template;
|
||||
form.language.value = lib.language || "";
|
||||
form.dataset.editId = lib.id;
|
||||
$("#lib-form-title").textContent = `Library „${lib.name}“ bearbeiten`;
|
||||
$("#lib-form-submit").textContent = "Speichern";
|
||||
@@ -199,7 +202,10 @@ $("#search-form").addEventListener("submit", async (e) => {
|
||||
searchBtn.disabled = true;
|
||||
searchBtn.innerHTML = '<span class="spinner"></span> Suche…';
|
||||
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) {
|
||||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[type]}</p>`;
|
||||
return;
|
||||
@@ -214,7 +220,7 @@ $("#search-form").addEventListener("submit", async (e) => {
|
||||
<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>
|
||||
<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}">` : ""}
|
||||
<div class="row">
|
||||
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
|
||||
@@ -955,8 +961,12 @@ async function runQuickSearch() {
|
||||
const box = $("#quick-results");
|
||||
box.innerHTML = "<p class='muted'>Suche läuft…</p>";
|
||||
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(
|
||||
`/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) {
|
||||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
|
||||
@@ -970,6 +980,7 @@ async function runQuickSearch() {
|
||||
r.narrator ? "🎙 " + r.narrator : "",
|
||||
r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "",
|
||||
r.year ?? "",
|
||||
LANGUAGE_NAMES[r.language] || r.language,
|
||||
].filter(Boolean).map(esc).join(" · ");
|
||||
return `<div class="card">
|
||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
<option value="audiobook">Audiobook</option>
|
||||
<option value="comic">Comic/Manga</option>
|
||||
</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 />
|
||||
<button type="submit" id="search-btn">Suchen</button>
|
||||
<button type="button" id="manual-btn" class="secondary">
|
||||
@@ -237,6 +242,7 @@
|
||||
<th>Pfad</th>
|
||||
<th>Ordner-Schema</th>
|
||||
<th>Datei-Schema</th>
|
||||
<th>Sprache</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -267,6 +273,11 @@
|
||||
name="file_template"
|
||||
placeholder="Datei-Schema (optional)"
|
||||
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="button" id="lib-form-cancel" class="secondary" hidden>
|
||||
Abbrechen
|
||||
|
||||
@@ -647,3 +647,43 @@ def test_append_without_the_flag_is_still_refused(client):
|
||||
assert not res["ok"]
|
||||
assert "bereits importiert" in res["error"]
|
||||
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"
|
||||
|
||||
@@ -15,6 +15,7 @@ class LibraryIn(BaseModel):
|
||||
root_path: str
|
||||
folder_template: str | None = None
|
||||
file_template: str | None = None
|
||||
language: str = "" # "german" / "english" - filters the Audible search
|
||||
|
||||
|
||||
class LibraryOut(LibraryIn):
|
||||
@@ -44,6 +45,7 @@ def create_library(data: LibraryIn, session: Session = Depends(get_session)):
|
||||
root_path=data.root_path,
|
||||
folder_template=data.folder_template or defaults["folder"],
|
||||
file_template=data.file_template or defaults["file"],
|
||||
language=data.language,
|
||||
)
|
||||
session.add(lib)
|
||||
session.commit()
|
||||
@@ -62,6 +64,7 @@ def update_library(library_id: int, data: LibraryIn, session: Session = Depends(
|
||||
lib.root_path = data.root_path
|
||||
lib.folder_template = data.folder_template or defaults["folder"]
|
||||
lib.file_template = data.file_template or defaults["file"]
|
||||
lib.language = data.language
|
||||
session.commit()
|
||||
return lib
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
|
||||
@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)
|
||||
if not provider:
|
||||
raise HTTPException(400, f"invalid media_type: {media_type}")
|
||||
try:
|
||||
return await provider(q)
|
||||
return await provider(q, language)
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"metadata provider error: {exc}")
|
||||
|
||||
|
||||
+10
-3
@@ -19,6 +19,9 @@ class Library(Base):
|
||||
root_path: Mapped[str] = mapped_column(String)
|
||||
folder_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")
|
||||
|
||||
@@ -58,9 +61,13 @@ def init_db(db_path=None):
|
||||
Base.metadata.create_all(_engine)
|
||||
# lightweight migration: create_all doesn't add new columns to existing tables
|
||||
with _engine.begin() as conn:
|
||||
cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(requests)")]
|
||||
if "narrator" not in cols:
|
||||
conn.exec_driver_sql("ALTER TABLE requests ADD COLUMN narrator VARCHAR NOT NULL DEFAULT ''")
|
||||
def add_column(table: str, column: str, ddl: str) -> None:
|
||||
cols = [row[1] for row in conn.exec_driver_sql(f"PRAGMA table_info({table})")]
|
||||
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)
|
||||
return _engine
|
||||
|
||||
|
||||
@@ -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:
|
||||
resp = await client.post(
|
||||
"https://graphql.anilist.co",
|
||||
|
||||
@@ -7,6 +7,9 @@ from .base import MetadataResult
|
||||
|
||||
# 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(",")]
|
||||
# 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"
|
||||
PAGE_SIZE = 50
|
||||
@@ -56,6 +59,7 @@ def _to_result(p: dict) -> MetadataResult:
|
||||
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 []),
|
||||
language=(p.get("language") or "").lower(),
|
||||
series=first.get("title", ""),
|
||||
series_id=first.get("asin", ""),
|
||||
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:
|
||||
region_results = await asyncio.gather(
|
||||
*(_query(client, tld, query) for tld in REGIONS)
|
||||
*(_query(client, tld, query) for tld in regions)
|
||||
)
|
||||
|
||||
results = []
|
||||
@@ -80,6 +85,9 @@ async def search(query: str) -> list[MetadataResult]:
|
||||
continue
|
||||
seen_asins.add(asin)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ class MetadataResult(BaseModel):
|
||||
narrator: str = ""
|
||||
external_id: str = ""
|
||||
year: int | None = None
|
||||
language: str = "" # audible: "english", "german", …
|
||||
series: str = ""
|
||||
series_id: str = ""
|
||||
volume: int | None = None
|
||||
|
||||
@@ -7,7 +7,8 @@ from .base import MetadataResult
|
||||
_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()
|
||||
isbn = query.replace("-", "").replace(" ", "")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
|
||||
Reference in New Issue
Block a user