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\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():