102 lines
4.3 KiB
Python
102 lines
4.3 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
|
|
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
|