Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree, French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration, découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
153 lines
5.5 KiB
Python
153 lines
5.5 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},
|
|
)
|
|
|
|
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()
|