diff --git a/README.md b/README.md index 70cc936..01afb8c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas** ## 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**. - 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`). 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. diff --git a/tests/test_audible.py b/tests/test_audible.py new file mode 100644 index 0000000..0af9683 --- /dev/null +++ b/tests/test_audible.py @@ -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 ???")) == [] diff --git a/wordarr/metadata/audible.py b/wordarr/metadata/audible.py index 58da99c..15a8091 100644 --- a/wordarr/metadata/audible.py +++ b/wordarr/metadata/audible.py @@ -21,7 +21,9 @@ SERIES_CHUNK = 4 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 = { "keywords": query, "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: break pages = min(SERIES_MAX_PAGES, SERIES_MAX_CALLS - calls) - for start in range(1, pages + 1, SERIES_CHUNK): - chunk = range(start, min(start + SERIES_CHUNK, pages + 1)) + for start in range(0, pages, SERIES_CHUNK): + chunk = range(start, min(start + SERIES_CHUNK, pages)) calls += len(chunk) results = await asyncio.gather(*( _query(client, tld, query, num_results=PAGE_SIZE, page=page, sort=None)