diff --git a/app/services/torznab.py b/app/services/torznab.py index 6a6768a..682e980 100644 --- a/app/services/torznab.py +++ b/app/services/torznab.py @@ -17,6 +17,7 @@ Formats : import asyncio import hashlib import logging +import re import time from dataclasses import dataclass from datetime import UTC, datetime @@ -48,6 +49,11 @@ _MAX_RELEASES = 100 # borne du flux retourné _SEARCH_TIMEOUT = 40.0 # scraping lent : garde-fou par source _EPISODE_CACHE_TTL = 600.0 # listes d'épisodes re-scrapées au bout de 10 min +# Recherche anime Sonarr : t=search&q=Titre 02 — le numéro d'épisode ABSOLU +# est suffixé au titre (2 à 4 chiffres). Sans décodage, le scraper reçoit une +# chaîne introuvable (« Back Street Girls GOKUDOLLS 02 ») et renvoie n'importe quoi. +_ANIME_ABS_RE = re.compile(r"^(?P.+?)\s+(?P<abs>\d{2,4})$") + @dataclass class Release: @@ -149,7 +155,13 @@ class TorznabService: # ------------------------------------------------------------ recherche async def tvsearch(self, q: str, season: int | None = None, ep: int | None = None) -> list[Release]: - """Recherche type Sonarr : série (+ saison/épisode optionnels).""" + """Recherche type Sonarr : série (+ saison/épisode optionnels). + + Le titre publié reprend la requête Sonarr (titre TVDB ou alias) plutôt + que le titre du scraper : c'est ce titre-là que Sonarr reconnaît lors + du parsing de la release. + """ + q, ep = self._parse_sonarr_query(q, ep) releases: list[Release] = [] for source in await self._enabled_sources(): outcomes = await self._search_source(source, q) @@ -159,12 +171,21 @@ class TorznabService: except (ScrapeError, TimeoutError): continue releases.extend( - self._releases_for(result.title, source, result.source_id, episodes, season, ep) + self._releases_for(q, source, result.source_id, episodes, season, ep) ) if len(releases) >= _MAX_RELEASES: return releases[:_MAX_RELEASES] return releases + @staticmethod + def _parse_sonarr_query(q: str, ep: int | None) -> tuple[str, int | None]: + """Extrait le numéro absolu suffixé par Sonarr (« Titre 02 » → ep=2).""" + if ep is None: + match = _ANIME_ABS_RE.match(q.strip()) + if match: + return match.group("title").strip(), int(match.group("abs")) + return q, ep + async def search(self, q: str) -> list[Release]: """Recherche libre : tous les épisodes des fiches trouvées.""" return await self.tvsearch(q) diff --git a/tests/test_torznab.py b/tests/test_torznab.py index a7808e1..df78845 100644 --- a/tests/test_torznab.py +++ b/tests/test_torznab.py @@ -128,6 +128,37 @@ async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source): assert "Frieren S02E01 VOSTFR WEB-DL" in titles +async def test_torznab_anime_search_absolute_episode(client, apikey, fake_source): + # Requête anime Sonarr : t=search&q=Titre 02 (numéro absolu suffixé au titre) + r = await client.get( + "/torznab/api", params={"t": "search", "q": "Frieren 02", "apikey": apikey} + ) + assert r.status_code == 200 + root = ET.fromstring(r.text) + items = root.findall(".//item") + assert len(items) == 1 + assert items[0].find("title").text == "Frieren S01E02 VOSTFR WEB-DL" + + +async def test_torznab_release_title_uses_sonarr_query(client, apikey, fake_source): + # Le titre publié reprend la requête Sonarr (titre/alias TVDB), pas le titre + # du scraper : sinon Sonarr rejette la release en « Unknown Series ». + r = await client.get( + "/torznab/api", + params={ + "t": "tvsearch", + "q": "Frieren: Beyond Journey's End", + "season": 1, + "ep": 2, + "apikey": apikey, + }, + ) + root = ET.fromstring(r.text) + items = root.findall(".//item") + assert len(items) == 1 + assert items[0].find("title").text == "Frieren: Beyond Journey's End S01E02 VOSTFR WEB-DL" + + async def test_torznab_search_without_query_serves_latest(client, apikey, fake_source): # Sans q : flux RSS des nouveautés (RSS sync / test Sonarr), pas une erreur 400. async def fake_latest():