v0.1.0 — Réécriture complète de OhmStreaming
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).
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user