"""Plex playback: image/subtitle proxies, HLS streaming, and watch-state writes."""
import threading
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
Path,
Query,
Request,
Response,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.auth import current_user, resolved_user_id
from app.db import SessionLocal, get_db
from app.models import User
from app.plex.models import PlexItem, PlexSeason, PlexShow, PlexState
from app.plex import imgcache, subtitles
from app.plex import paths as plex_paths
from app.plex.lookups import by_rating_key
from app.plex import people as plex_people
from app.plex import stream as plex_stream
from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.routes.plex._shared import item_or_404
from app.refusal import refused
from app.routes._common import ProgressIn, not_found
router = APIRouter(prefix="/api/plex", tags=["plex"])
def _require_session(request: Request) -> int:
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
low-sensitivity; a valid session is enough."""
uid, _ = resolved_user_id(request)
if not uid:
raise refused(401, "not_authenticated")
return uid
@router.get("/image/{rating_key}")
def image(rating_key: str, request: Request, variant: str = "thumb") -> Response:
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A
thin on-disk cache serves repeat views without re-hitting Plex.
⚠️ This endpoint is called in BURSTS (a collections page fires 100+ concurrent poster requests),
so it must NOT hold a DB connection during the slow Plex fetch — doing so exhausted the pool
(QueuePool timeout, broken images on prod). Warm path (cache hit) touches NO DB; cold path opens a
brief session only to resolve the image key + read the Plex config, then RELEASES it before the
fetch. Auth is the signed-session check (no DB user-load)."""
_require_session(request)
v = variant if variant in imgcache.SIZES else "thumb"
w, h = imgcache.SIZES[v]
# Size is part of the cache key: Plex resizes server-side (see PlexClient.image_bytes), so we cache
# the small variant. The width tag also means changing a target size just re-caches, never serves a
# stale differently-sized file (old full-size files under the un-tagged key are simply orphaned).
cache_key = f"item_{rating_key}_{v}_{w}"
hit = imgcache.read(cache_key)
if hit:
return imgcache.response(*hit)
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
try:
with SessionLocal() as db:
key = imgcache.key_for(db, str(rating_key), imgcache.picture(v))
if not key:
raise not_found("image")
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
except PlexNotConfigured:
raise refused(400, "plex_not_configured") from None
try:
with plex:
data, ct = plex.image_bytes(key, w, h)
# silent-ok: a byte proxy for an
; the browser shows the placeholder and no reader sees the
# detail — the 502 is the whole answer, as for the person-image proxy below.
except PlexError:
raise refused(502, "image_fetch_failed") from None
imgcache.write(cache_key, data, ct)
return imgcache.response(data, ct)
# Both subtitle routes authenticate with the full `current_user`, not the cookie-only
# `_require_session` the image proxies use. That lighter gate exists for bursts of low-sensitivity
# poster fetches and skips the is_active / is_suspended / session-epoch checks — acceptable for a
# poster, not for a route that starts reads of the media library: a suspended or "log out
# everywhere"-revoked cookie kept scheduling them. They are not bursty (one poll a second, one fetch
# per track), so the DB user-load costs nothing that matters.
#
# Both path parameters are bounded, the same way on both routes: they become a cache FILE name
# (`sub__`), and an absurd one — a 250-digit key or ordinal — outgrew the OS's name
# limit and turned the status poll into a 500. A Plex rating_key is a short number; the bound only
# has to be generous.
_RatingKey = Path(max_length=32)
_SubOrd = Path(ge=0, le=9999)
@router.get("/subtitle/{rating_key}/{sub_ord}/status")
def subtitle_status(
rating_key: str = _RatingKey,
sub_ord: int = _SubOrd,
user: User = Depends(current_user),
# Bounded both ways: a non-finite position used to reach the scheduler's arithmetic and 500, and
# an absurd one reached ffmpeg's `-ss`, which refused it.
at: float = Query(0.0, ge=0.0, le=subtitles.MAX_POSITION_S, allow_inf_nan=False),
) -> dict:
"""Is this subtitle track showable yet — and if not, get it moving. Extraction runs in the
background (see plex/subtitles.py for the measurements that forced it there), so the player polls
this while it waits instead of hanging on a