fix(metadata): audible page parameter is 0-based
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 ???")) == []
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user