Persist Seerr media failure suppression and reduce sync error noise
This commit is contained in:
@@ -42,6 +42,9 @@ from ..db import (
|
||||
set_setting,
|
||||
update_artwork_cache_stats,
|
||||
cleanup_history,
|
||||
is_seerr_media_failure_suppressed,
|
||||
record_seerr_media_failure,
|
||||
clear_seerr_media_failure,
|
||||
)
|
||||
from ..models import Snapshot, TriageResult, RequestType
|
||||
from ..services.snapshot import build_snapshot
|
||||
@@ -50,6 +53,8 @@ router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(
|
||||
|
||||
CACHE_TTL_SECONDS = 600
|
||||
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
FAILED_DETAIL_CACHE_TTL_SECONDS = 3600
|
||||
_failed_detail_cache: Dict[str, float] = {}
|
||||
REQUEST_CACHE_TTL_SECONDS = 600
|
||||
logger = logging.getLogger(__name__)
|
||||
_sync_state: Dict[str, Any] = {
|
||||
@@ -100,6 +105,45 @@ def _cache_get(key: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
def _cache_set(key: str, payload: Dict[str, Any]) -> None:
|
||||
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
|
||||
_failed_detail_cache.pop(key, None)
|
||||
|
||||
|
||||
def _failure_cache_has(key: str) -> bool:
|
||||
expires_at = _failed_detail_cache.get(key)
|
||||
if not expires_at:
|
||||
return False
|
||||
if expires_at < time.time():
|
||||
_failed_detail_cache.pop(key, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _failure_cache_set(key: str, ttl_seconds: int = FAILED_DETAIL_CACHE_TTL_SECONDS) -> None:
|
||||
_failed_detail_cache[key] = time.time() + ttl_seconds
|
||||
|
||||
|
||||
def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
|
||||
response = exc.response
|
||||
if response is None:
|
||||
return None
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = response.text
|
||||
if isinstance(payload, dict):
|
||||
message = payload.get("message") or payload.get("error")
|
||||
return str(message).strip() if message else json.dumps(payload, ensure_ascii=True)
|
||||
if isinstance(payload, str):
|
||||
trimmed = payload.strip()
|
||||
return trimmed or None
|
||||
return str(payload)
|
||||
|
||||
|
||||
def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
|
||||
response = exc.response
|
||||
if response is None:
|
||||
return False
|
||||
return response.status_code == 404 or response.status_code >= 500
|
||||
|
||||
|
||||
def _status_label(value: Any) -> str:
|
||||
@@ -383,9 +427,12 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
|
||||
cached = _cache_get(cache_key)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
if _failure_cache_has(cache_key):
|
||||
return None
|
||||
try:
|
||||
fetched = await client.get_request(str(request_id))
|
||||
except httpx.HTTPStatusError:
|
||||
_failure_cache_set(cache_key)
|
||||
return None
|
||||
if isinstance(fetched, dict):
|
||||
_cache_set(cache_key, fetched)
|
||||
@@ -393,54 +440,80 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
|
||||
return None
|
||||
|
||||
|
||||
async def _get_media_details(
|
||||
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not tmdb_id or not media_type:
|
||||
return None
|
||||
normalized_media_type = str(media_type).strip().lower()
|
||||
if normalized_media_type not in {"movie", "tv"}:
|
||||
return None
|
||||
cache_key = f"media:{normalized_media_type}:{int(tmdb_id)}"
|
||||
cached = _cache_get(cache_key)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
if is_seerr_media_failure_suppressed(normalized_media_type, int(tmdb_id)):
|
||||
logger.debug(
|
||||
"Seerr media hydration suppressed from db: media_type=%s tmdb_id=%s",
|
||||
normalized_media_type,
|
||||
tmdb_id,
|
||||
)
|
||||
_failure_cache_set(cache_key, ttl_seconds=FAILED_DETAIL_CACHE_TTL_SECONDS)
|
||||
return None
|
||||
if _failure_cache_has(cache_key):
|
||||
return None
|
||||
try:
|
||||
if normalized_media_type == "movie":
|
||||
fetched = await client.get_movie(int(tmdb_id))
|
||||
else:
|
||||
fetched = await client.get_tv(int(tmdb_id))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
_failure_cache_set(cache_key)
|
||||
if _should_persist_seerr_media_failure(exc):
|
||||
record_seerr_media_failure(
|
||||
normalized_media_type,
|
||||
int(tmdb_id),
|
||||
status_code=exc.response.status_code if exc.response is not None else None,
|
||||
error_message=_extract_http_error_message(exc),
|
||||
)
|
||||
return None
|
||||
if isinstance(fetched, dict):
|
||||
clear_seerr_media_failure(normalized_media_type, int(tmdb_id))
|
||||
_cache_set(cache_key, fetched)
|
||||
return fetched
|
||||
return None
|
||||
|
||||
|
||||
async def _hydrate_title_from_tmdb(
|
||||
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
|
||||
) -> tuple[Optional[str], Optional[int]]:
|
||||
if not tmdb_id or not media_type:
|
||||
return None, None
|
||||
try:
|
||||
if media_type == "movie":
|
||||
details = await client.get_movie(int(tmdb_id))
|
||||
if isinstance(details, dict):
|
||||
title = details.get("title")
|
||||
release_date = details.get("releaseDate")
|
||||
year = int(release_date[:4]) if release_date else None
|
||||
return title, year
|
||||
if media_type == "tv":
|
||||
details = await client.get_tv(int(tmdb_id))
|
||||
if isinstance(details, dict):
|
||||
title = details.get("name") or details.get("title")
|
||||
first_air = details.get("firstAirDate")
|
||||
year = int(first_air[:4]) if first_air else None
|
||||
return title, year
|
||||
except httpx.HTTPStatusError:
|
||||
details = await _get_media_details(client, media_type, tmdb_id)
|
||||
if not isinstance(details, dict):
|
||||
return None, None
|
||||
normalized_media_type = str(media_type).strip().lower() if media_type else None
|
||||
if normalized_media_type == "movie":
|
||||
title = details.get("title")
|
||||
release_date = details.get("releaseDate")
|
||||
year = int(release_date[:4]) if release_date else None
|
||||
return title, year
|
||||
if normalized_media_type == "tv":
|
||||
title = details.get("name") or details.get("title")
|
||||
first_air = details.get("firstAirDate")
|
||||
year = int(first_air[:4]) if first_air else None
|
||||
return title, year
|
||||
return None, None
|
||||
|
||||
|
||||
async def _hydrate_artwork_from_tmdb(
|
||||
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
if not tmdb_id or not media_type:
|
||||
details = await _get_media_details(client, media_type, tmdb_id)
|
||||
if not isinstance(details, dict):
|
||||
return None, None
|
||||
try:
|
||||
if media_type == "movie":
|
||||
details = await client.get_movie(int(tmdb_id))
|
||||
if isinstance(details, dict):
|
||||
return (
|
||||
details.get("posterPath") or details.get("poster_path"),
|
||||
details.get("backdropPath") or details.get("backdrop_path"),
|
||||
)
|
||||
if media_type == "tv":
|
||||
details = await client.get_tv(int(tmdb_id))
|
||||
if isinstance(details, dict):
|
||||
return (
|
||||
details.get("posterPath") or details.get("poster_path"),
|
||||
details.get("backdropPath") or details.get("backdrop_path"),
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
return None, None
|
||||
return None, None
|
||||
return (
|
||||
details.get("posterPath") or details.get("poster_path"),
|
||||
details.get("backdropPath") or details.get("backdrop_path"),
|
||||
)
|
||||
|
||||
|
||||
def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]:
|
||||
|
||||
Reference in New Issue
Block a user