fix(metadata): audible page parameter is 0-based

This commit is contained in:
Steppenstreuner
2026-08-28 20:07:30 +02:00
parent 0844d303db
commit f4177339f2
3 changed files with 59 additions and 4 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas**
## Funktionsweise ## Funktionsweise
1. **Anfragen**: Im Web-UI per Titel/Autor/ISBN suchen (Ebooks: Open Library · Audiobooks: Audible · Manga: AniList) oder manuell anlegen. Beim Request wählst du die **Ziel-Library** (z.B. deine 8 Bookorbit-Libraries oder die 3 Audiobookshelf-Libraries english/adult/kids). Der Titel erscheint als **Missing**. 1. **Anfragen**: Im Web-UI per Titel/Autor/ISBN suchen (Ebooks: Open Library · Audiobooks: Audible · Manga: AniList) oder manuell anlegen. Beim Request wählst du die **Ziel-Library** (z.B. deine 8 Bookorbit-Libraries oder die 3 Audiobookshelf-Libraries english/adult/kids). Der Titel erscheint als **Missing**.
Gehört ein Audiobook-Treffer zu einer Serie, legt **„Ganze Serie…"** alle Folgen auf einmal an (Folgennummer + echter Titel von Audible, optional auf einen Folgenbereich eingegrenzt). Audible hat keine Serien-Abfrage — wordarr sammelt die Folgen über mehrere Suchläufe ein, einzelne können fehlen und werden im Dialog als Lücke angezeigt. Gehört ein Audiobook-Treffer zu einer Serie, legt **„Ganze Serie…"** alle Folgen auf einmal an (Folgennummer + echter Titel von Audible, optional auf einen Folgenbereich eingegrenzt). Audible hat keine Serien-Abfrage — wordarr sammelt die Folgen über mehrere Suchläufe ein; sollte eine fehlen, zeigt der Dialog die Lücke an.
2. **Download-Ordner**: wordarr überwacht keinen Downloader aktiv — du legst Dateien selbst in den Download-Ordner (`/mnt/downloads`). 2. **Download-Ordner**: wordarr überwacht keinen Downloader aktiv — du legst Dateien selbst in den Download-Ordner (`/mnt/downloads`).
3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert. 3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert.
+53
View File
@@ -0,0 +1,53 @@
import asyncio
import httpx
import pytest
from wordarr.metadata import audible
@pytest.fixture
def calls(monkeypatch):
"""Capture the query params audible.py sends, answering from a fake catalog."""
seen = []
def handler(request: httpx.Request) -> httpx.Response:
params = dict(request.url.params)
seen.append(params)
# only the first page carries results, like a query with few hits
if int(params.get("page", 0)) > 0:
return httpx.Response(200, json={"products": []})
return httpx.Response(200, json={"products": [{
"asin": "ASIN1",
"title": "Die drei ??? und der Super-Papagei",
"authors": [{"name": "H.G. Francis"}],
"series": [{"asin": "SERIES1", "title": "Die drei ???", "sequence": "1"}],
}]})
real_client = httpx.AsyncClient
monkeypatch.setattr(audible.httpx, "AsyncClient",
lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw))
return seen
def test_search_asks_for_the_first_page(calls):
"""Audible's page parameter is 0-based: page=1 skips the only page of results
and a search like "Der Superpapagei" comes back empty."""
results = asyncio.run(audible.search("Der Superpapagei"))
assert [r.title for r in results] == ["Die drei ??? und der Super-Papagei"]
assert calls and all(int(c["page"]) == 0 for c in calls)
assert all(c["products_sort_by"] == "Relevance" for c in calls)
def test_series_crawl_starts_at_page_zero(calls):
episodes = asyncio.run(audible.series_episodes("SERIES1", "Die drei ???"))
assert [e.volume for e in episodes] == [1]
assert episodes[0].series_id == "SERIES1"
pages = {int(c["page"]) for c in calls}
assert 0 in pages and max(pages) > 0
# relevance sorting shrinks a deep sweep, so the crawl must not send it
assert all("products_sort_by" not in c for c in calls)
def test_series_crawl_ignores_other_series(calls):
assert asyncio.run(audible.series_episodes("OTHER", "Die drei ???")) == []
+5 -3
View File
@@ -21,7 +21,9 @@ SERIES_CHUNK = 4
async def _query(client: httpx.AsyncClient, tld: str, query: str, num_results: int = 10, async def _query(client: httpx.AsyncClient, tld: str, query: str, num_results: int = 10,
page: int = 1, sort: str | None = "Relevance") -> list[dict]: page: int = 0, sort: str | None = "Relevance") -> list[dict]:
# NB: page is 0-based - page=1 is the *second* page and comes back empty for
# any query with fewer than num_results hits.
params = { params = {
"keywords": query, "keywords": query,
"num_results": num_results, "num_results": num_results,
@@ -106,8 +108,8 @@ async def series_episodes(series_id: str, series_title: str) -> list[MetadataRes
if calls >= SERIES_MAX_CALLS: if calls >= SERIES_MAX_CALLS:
break break
pages = min(SERIES_MAX_PAGES, SERIES_MAX_CALLS - calls) pages = min(SERIES_MAX_PAGES, SERIES_MAX_CALLS - calls)
for start in range(1, pages + 1, SERIES_CHUNK): for start in range(0, pages, SERIES_CHUNK):
chunk = range(start, min(start + SERIES_CHUNK, pages + 1)) chunk = range(start, min(start + SERIES_CHUNK, pages))
calls += len(chunk) calls += len(chunk)
results = await asyncio.gather(*( results = await asyncio.gather(*(
_query(client, tld, query, num_results=PAGE_SIZE, page=page, sort=None) _query(client, tld, query, num_results=PAGE_SIZE, page=page, sort=None)