"""Private Jellyfin thumbnails for items returned in a user's own viewing history.""" import asyncio import hashlib import hmac import re import time from collections import OrderedDict import httpx from fastapi import HTTPException from ..config import settings TOKEN_SECONDS = 3600 MAX_IMAGE_BYTES = 1024 * 1024 MAX_CACHE_BYTES = 16 * 1024 * 1024 _cache = OrderedDict() _downloads = asyncio.Semaphore(6) def item_id(value): value = str(value or "").replace("-", "").lower() return value if re.fullmatch(r"[a-f0-9]{32}", value) else None def source(runtime): return hashlib.sha256(f"{runtime.jellyfin_base_url}|{runtime.jellyfin_api_key}".encode()).hexdigest() def signature(user, runtime, media_id, expires): message = f"insights-artwork\n{user['username']}\n{source(runtime)}\n{media_id}\n{expires}" return hmac.new(settings.jwt_secret.encode(), message.encode(), hashlib.sha256).hexdigest() def with_artwork(data, user, runtime): expires = int(time.time()) + TOKEN_SECONDS result = {**data} for field in ("recent", "top_titles"): rows = [] for play in data.get(field, []): row = {**play} media_id = row.pop("artwork_item_id", None) row["artwork_url"] = None if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key: token = f"{expires}.{signature(user, runtime, media_id, expires)}" row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}" rows.append(row) result[field] = rows return result def verify_artwork_token(user, runtime, media_id, token): if not settings.jwt_secret or not re.fullmatch(r"[a-f0-9]{32}", media_id): raise HTTPException(404, "Artwork unavailable") if not re.fullmatch(r"[0-9]{1,12}\.[a-f0-9]{64}", token): raise HTTPException(403, "Artwork link is invalid or expired") try: expires_text, supplied = token.split(".", 1) expires = int(expires_text) except (ValueError, TypeError): raise HTTPException(403, "Artwork link is invalid or expired") from None now = int(time.time()) if expires < now or expires > now + TOKEN_SECONDS or not hmac.compare_digest(supplied, signature(user, runtime, media_id, expires)): raise HTTPException(403, "Artwork link is invalid or expired") async def get_artwork(user, runtime, media_id, token): verify_artwork_token(user, runtime, media_id, token) if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key: raise HTTPException(404, "Artwork unavailable") key = (source(runtime), media_id) async with _downloads: cached = _cache.get(key) if cached and cached[0] > time.monotonic(): _cache.move_to_end(key) return cached[1], cached[2] try: async with httpx.AsyncClient(timeout=8.0) as client: async with client.stream("GET", f"{runtime.jellyfin_base_url.rstrip('/')}/Items/{media_id}/Images/Primary", headers={"X-Emby-Token": runtime.jellyfin_api_key}, params={"maxWidth": 120, "maxHeight": 180, "quality": 85, "format": "Webp"}) as response: response.raise_for_status() content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() if content_type not in {"image/jpeg", "image/png", "image/webp"}: raise ValueError() content = bytearray() async for chunk in response.aiter_bytes(): content.extend(chunk) if len(content) > MAX_IMAGE_BYTES: raise ValueError() if not content: raise ValueError() except (httpx.HTTPError, ValueError) as exc: raise HTTPException(404, "Artwork unavailable") from exc for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]: _cache.pop(expired, None) while _cache and (len(_cache) >= 128 or sum(len(value[1]) for value in _cache.values()) + len(content) > MAX_CACHE_BYTES): _cache.popitem(last=False) _cache[key] = (time.monotonic() + 600, bytes(content), content_type) return bytes(content), content_type