Files
Magent/backend/app/services/insights_artwork.py
T
Assclaw e7e4c9eff3
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m9s
Fix viewing-history artwork and add transcode playback metrics
2026-09-08 16:15:16 +12:00

99 lines
4.2 KiB
Python

"""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
recent = []
for play in data.get("recent", []):
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}"
recent.append(row)
return {**data, "recent": recent}
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