Torznab : recherche anime Sonarr (numéro absolu) + titre de release aligné sur la requête
docker / build-push (push) Successful in 27s

- t=search&q=Titre 02 : le numéro absolu suffixé par Sonarr est extrait et
  utilisé comme filtre épisode, au lieu de partir tel quel au scraper
  (chaîne introuvable → résultats parasites)
- le titre des releases reprend la requête Sonarr (titre/alias TVDB) plutôt
  que le titre du scraper : Sonarr reconnaît sa série au lieu de rejeter
  en « Unknown Series »
This commit is contained in:
Roman
2026-09-27 11:10:06 +00:00
parent 28877bda4f
commit 2581372af8
2 changed files with 54 additions and 2 deletions
+23 -2
View File
@@ -17,6 +17,7 @@ Formats :
import asyncio import asyncio
import hashlib import hashlib
import logging import logging
import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime 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 _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 _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<title>.+?)\s+(?P<abs>\d{2,4})$")
@dataclass @dataclass
class Release: class Release:
@@ -149,7 +155,13 @@ class TorznabService:
# ------------------------------------------------------------ recherche # ------------------------------------------------------------ recherche
async def tvsearch(self, q: str, season: int | None = None, ep: int | None = None) -> list[Release]: 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] = [] releases: list[Release] = []
for source in await self._enabled_sources(): for source in await self._enabled_sources():
outcomes = await self._search_source(source, q) outcomes = await self._search_source(source, q)
@@ -159,12 +171,21 @@ class TorznabService:
except (ScrapeError, TimeoutError): except (ScrapeError, TimeoutError):
continue continue
releases.extend( 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: if len(releases) >= _MAX_RELEASES:
return releases[:_MAX_RELEASES] return releases[:_MAX_RELEASES]
return 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]: async def search(self, q: str) -> list[Release]:
"""Recherche libre : tous les épisodes des fiches trouvées.""" """Recherche libre : tous les épisodes des fiches trouvées."""
return await self.tvsearch(q) return await self.tvsearch(q)
+31
View File
@@ -128,6 +128,37 @@ async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source):
assert "Frieren S02E01 VOSTFR WEB-DL" in titles 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): 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. # Sans q : flux RSS des nouveautés (RSS sync / test Sonarr), pas une erreur 400.
async def fake_latest(): async def fake_latest():