"""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}, ) 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()