diff --git a/README.md b/README.md
index 58aef3f..7d8bf19 100644
--- a/README.md
+++ b/README.md
@@ -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}`.
diff --git a/static/app.js b/static/app.js
index 44e2986..8629a9b 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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) => `
${esc(l.name)} ${esc(l.media_type)} ${esc(l.root_path)}
${esc(l.folder_template)} ${esc(l.file_template)}
+ ${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"}
Bearbeiten
Löschen
@@ -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 = ' 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 = `${EMPTY_HINTS[type]}
`;
return;
@@ -214,7 +220,7 @@ $("#search-form").addEventListener("submit", async (e) => {
${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) : ""}
+ ${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}
${type === "comic" ? ` ` : ""}
${opts || "— keine Library — "}
@@ -955,8 +961,12 @@ async function runQuickSearch() {
const box = $("#quick-results");
box.innerHTML = "
Suche läuft…
";
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 = `
${EMPTY_HINTS[item.media_type]}
`;
@@ -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 `
${r.cover_url ? `
` : '
?
'}
diff --git a/static/index.html b/static/index.html
index 4f5a843..9be7586 100644
--- a/static/index.html
+++ b/static/index.html
@@ -30,6 +30,11 @@
Audiobook
Comic/Manga
+
+ Alle Sprachen
+ Deutsch
+ Englisch
+
Suchen
@@ -237,6 +242,7 @@
Pfad
Ordner-Schema
Datei-Schema
+ Sprache
@@ -267,6 +273,11 @@
name="file_template"
placeholder="Datei-Schema (optional)"
class="wide" />
+
+ Sprache: egal
+ Deutsch
+ Englisch
+
Anlegen
Abbrechen
diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py
index 7c5f01f..3795715 100644
--- a/tests/test_import_flow.py
+++ b/tests/test_import_flow.py
@@ -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"
diff --git a/wordarr/api/libraries.py b/wordarr/api/libraries.py
index 3b3e17c..f996167 100644
--- a/wordarr/api/libraries.py
+++ b/wordarr/api/libraries.py
@@ -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
diff --git a/wordarr/api/search.py b/wordarr/api/search.py
index ed1a90d..864dc21 100644
--- a/wordarr/api/search.py
+++ b/wordarr/api/search.py
@@ -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}")
diff --git a/wordarr/db.py b/wordarr/db.py
index 6299fc1..b8d09d9 100644
--- a/wordarr/db.py
+++ b/wordarr/db.py
@@ -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
diff --git a/wordarr/metadata/anilist.py b/wordarr/metadata/anilist.py
index b4e7efd..aeb7705 100644
--- a/wordarr/metadata/anilist.py
+++ b/wordarr/metadata/anilist.py
@@ -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",
diff --git a/wordarr/metadata/audible.py b/wordarr/metadata/audible.py
index 15a8091..22c38ed 100644
--- a/wordarr/metadata/audible.py
+++ b/wordarr/metadata/audible.py
@@ -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
diff --git a/wordarr/metadata/base.py b/wordarr/metadata/base.py
index 2691cd8..0f17798 100644
--- a/wordarr/metadata/base.py
+++ b/wordarr/metadata/base.py
@@ -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
diff --git a/wordarr/metadata/openlibrary.py b/wordarr/metadata/openlibrary.py
index 4d1c54c..4748d22 100644
--- a/wordarr/metadata/openlibrary.py
+++ b/wordarr/metadata/openlibrary.py
@@ -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: