Files
roman 853b4e0866 Compatibilité Sonarr : flux RSS Torznab, catégories qBittorrent, save_path, TLS auto-signé
- torznab : tvsearch sans q sert les nouveautés (RSS sync + test Sonarr)
  au lieu d'une erreur 400 — ajoute TorznabService.latest_releases()
- qbit : POST /torrents/createCategory + GET /torrents/categories stateful
  (Sonarr crée sa catégorie tv-sonarr puis la revérifie)
- qbit : /app/preferences expose save_path=/downloads (health Sonarr)
- sonarr : verify=False sur httpx — reverse proxy swizzin en certificat
  auto-signé (usage LAN)
2026-09-22 18:25:03 +00:00

154 lines
5.6 KiB
Python

"""Client Sonarr (API v3) — personnalisation de « Pour toi ».
OhmStreaming lit ce qui est téléchargé/grabé sur Sonarr :
- `/api/v3/history` (événements grab/import) → titres récemment obtenus ;
- `/api/v3/series` → genres de chaque série (fournis par Sonarr lui-même).
Ces données alimentent le profil de genres des recommandations et la liste
d'exclusion (le déjà-possédé sur Sonarr n'est pas re-recommandé).
Toujours tolérant aux pannes : Sonarr absent/non configuré → profil vide,
la découverte continue de fonctionner avec l'historique local.
"""
import logging
import time
import httpx
from app.services.kitsu import normalize_title
from app.services.settings import get_sonarr_config
logger = logging.getLogger(__name__)
_HISTORY_PAGE_SIZE = 100
_MAX_TITLES = 30 # titres Sonarr récents analysés
_PROFILE_TTL = 900.0 # profil Sonarr re-quantifié au bout de 15 min
_REQUEST_TIMEOUT = 15.0
class SonarrService:
"""Profil de consommation Sonarr : titres téléchargés + genres associés."""
def __init__(self) -> None:
self._cache: tuple[float, tuple[set[str], dict[str, int]]] | None = None
async def _config(self) -> tuple[str, str]:
"""(url, apikey) — lu en DB à chaque usage (lecture SQLite négligeable)."""
config = await get_sonarr_config()
return config["url"], config["apikey"]
def invalidate(self) -> None:
"""Force le recalcul du profil au prochain appel (réglages modifiés)."""
self._cache = None
async def _client(self) -> httpx.AsyncClient | None:
url, apikey = await self._config()
if not url or not apikey:
return None
return httpx.AsyncClient(
base_url=url,
timeout=_REQUEST_TIMEOUT,
headers={"X-Api-Key": apikey},
verify=False, # reverse proxy swizzin : certificat auto-signé (LAN uniquement)
)
async def _get(self, path: str, params: dict | None = None) -> dict | list | None:
client = await self._client()
if client is None:
return None
try:
async with client:
response = await client.get(path, params=params)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Sonarr %s KO : %s", path, exc)
return None
# ------------------------------------------------------------ profil
async def profile(self) -> tuple[set[str], dict[str, int]]:
"""(titres possédés normalisés, comptage de genres) — vide si non configuré."""
if self._cache is not None and self._cache[0] > time.monotonic():
return self._cache[1]
titles = await self.downloaded_titles()
genres_by_title = await self.series_genres()
owned: set[str] = set()
genre_counts: dict[str, int] = {}
for title in titles:
normalized = normalize_title(title).casefold()
if normalized:
owned.add(normalized)
for genre in genres_by_title.get(normalized, []):
genre_counts[genre] = genre_counts.get(genre, 0) + 1
result = (owned, genre_counts)
self._cache = (time.monotonic() + _PROFILE_TTL, result)
return result
async def downloaded_titles(self) -> list[str]:
"""Titres récemment grabés/importés sur Sonarr (les plus récents d'abord)."""
payload = await self._get(
"/api/v3/history",
{
"page": 1,
"pageSize": _HISTORY_PAGE_SIZE,
"eventType": 1, # grab ; les imports (3) suivent le même titre
"sortKey": "date",
"sortDir": "desc",
},
)
if not isinstance(payload, dict):
return []
titles: list[str] = []
seen: set[str] = set()
for record in payload.get("records", []):
title = record.get("series", {}).get("title")
if not title or title in seen:
continue
seen.add(title)
titles.append(title)
if len(titles) >= _MAX_TITLES:
break
return titles
async def series_genres(self) -> dict[str, list[str]]:
"""Genres par série, clé = titre normalisé (minuscule)."""
payload = await self._get("/api/v3/series")
if not isinstance(payload, list):
return {}
return {
normalize_title(s.get("title", "")).casefold(): [
g for g in s.get("genres", []) if isinstance(g, str)
]
for s in payload
if s.get("title")
}
# ------------------------------------------------------------ admin
async def test_connection(self) -> dict:
"""Diagnostic admin : version Sonarr, séries, grabs récents."""
url, apikey = await self._config()
if not url or not apikey:
return {"ok": False, "detail": "URL ou clé API manquante"}
payload = await self._get("/api/v3/system/status")
if payload is None:
return {"ok": False, "detail": "Connexion impossible — vérifiez URL et clé"}
series = await self._get("/api/v3/series")
titles = await self.downloaded_titles()
return {
"ok": True,
"detail": (
f"Sonarr {payload.get('version', '?')} — "
f"{len(series) if isinstance(series, list) else 0} séries, "
f"{len(titles)} saisies récentes"
),
}
sonarr = SonarrService()