"""Plex integration API. Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo + pure-Siftlode included) — the module is an access layer to the Plex library WITHOUT requiring a plex.tv account, and playback is a local file. Admin endpoints test the connection and run the catalog sync. """ import logging from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session, aliased from app import sysconfig from app.auth import current_user from app.db import get_db from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User from app.plex import sync as plex_sync from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.routes.admin import admin_user from app.routes.feed import _to_tsquery_str log = logging.getLogger("siftlode.plex") router = APIRouter(prefix="/api/plex", tags=["plex"]) _TS_CONFIG = "public.unaccent_simple" # --- Admin: connectivity + sync --------------------------------------------------------------- @router.post("/test") def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Admin: verify the configured Plex server URL + token, returning the server name and the movie/show sections (for the config library-picker).""" try: with PlexClient(db) as plex: info = plex.server_info() sections = plex.sections() except PlexNotConfigured as e: raise HTTPException(status_code=400, detail=str(e)) except PlexError as e: raise HTTPException(status_code=422, detail=f"Plex connection failed: {e}") return { "ok": True, "server_name": info.get("friendlyName") or info.get("title1") or "Plex", "version": info.get("version"), "sections": [ {"key": str(s.get("key")), "title": s.get("title"), "type": s.get("type"), "count": s.get("count")} for s in sections if s.get("type") in ("movie", "show") ], } @router.post("/sync") def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a while on a large library); a background/scheduled sync also runs periodically.""" return plex_sync.sync(db) # --- Read: libraries / browse / show / image -------------------------------------------------- def _enabled() -> None: """Guard read endpoints when the module is off (avoids leaking a stale mirror).""" # Read endpoints stay usable as long as a mirror exists; the toggle only gates sync + UI. return None @router.get("/libraries") def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """The mirrored libraries the user can browse (drives the Plex scope selector).""" libs = db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all() out = [] for lib in libs: if lib.kind == "movie": count = db.query(func.count(PlexItem.id)).filter_by(library_id=lib.id, kind="movie").scalar() else: count = db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar() out.append({"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count or 0}) return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} def _movie_card(it: PlexItem, st: PlexState | None) -> dict: return { "id": it.rating_key, "type": "movie", "title": it.title, "year": it.year, "duration_seconds": it.duration_s, "thumb": f"/api/plex/image/{it.rating_key}", "playable": it.playable, "status": st.status if st else "new", "position_seconds": st.position_seconds if st else 0, } def _show_card(sh: PlexShow) -> dict: return { "id": sh.rating_key, "type": "show", "title": sh.title, "year": sh.year, "thumb": f"/api/plex/image/{sh.rating_key}", "season_count": sh.child_count, } def _episode_card(e: PlexItem, st: PlexState | None) -> dict: return { "id": e.rating_key, "type": "episode", "title": e.title, "summary": e.summary, "season_number": e.season_number, "episode_number": e.episode_number, "duration_seconds": e.duration_s, "thumb": f"/api/plex/image/{e.rating_key}", "playable": e.playable, "status": st.status if st else "new", "position_seconds": st.position_seconds if st else 0, } @router.get("/browse") def browse( library: str, q: str | None = None, sort: str = "added", show: str = "all", offset: int = 0, limit: int = Query(default=40, ge=1, le=100), user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user watch-state filter (`show`: all|unwatched|in_progress|watched — movie libraries only). Movie libraries return playable movie cards; show libraries return show cards (drill in via /show).""" lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() if lib is None: raise HTTPException(status_code=404, detail="Unknown Plex library") offset = max(0, offset) model = PlexItem if lib.kind == "movie" else PlexShow query = db.query(model) if lib.kind == "movie": query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie") # Per-user watch-state filter (movies only; a show isn't a single watch unit). st = aliased(PlexState) query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) if show == "watched": query = query.filter(st.status == "watched") elif show == "in_progress": query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")) elif show == "unwatched": query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))) else: # all — hide only the explicitly hidden query = query.filter(or_(st.status.is_(None), st.status != "hidden")) else: query = query.filter(PlexShow.library_id == lib.id) ts = _to_tsquery_str(q) if q else None tsq = None if ts: tsq = func.to_tsquery(_TS_CONFIG, ts) query = query.filter(model.search_vector.op("@@")(tsq)) total = query.count() if tsq is not None: order = [func.ts_rank(model.search_vector, tsq).desc()] elif sort == "title": order = [func.lower(model.title).asc()] else: # newest first order = [model.added_at.desc().nullslast()] order.append(model.id.desc()) rows = query.order_by(*order).offset(offset).limit(limit).all() if lib.kind == "movie": by_item = {r.id: r for r in rows} states = { s.item_id: s for s in db.query(PlexState).filter( PlexState.user_id == user.id, PlexState.item_id.in_(list(by_item.keys()) or [0]) ) } items = [_movie_card(r, states.get(r.id)) for r in rows] else: items = [_show_card(r) for r in rows] return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items} @router.get("/show/{rating_key}") def show_detail( rating_key: str, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """A show's seasons + episodes (the drill-down view) with per-user watch state.""" sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first() if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") seasons = ( db.query(PlexSeason).filter_by(show_id=sh.id).order_by(PlexSeason.season_number.asc().nullsfirst()).all() ) eps = ( db.query(PlexItem) .filter_by(show_id=sh.id, kind="episode") .order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst()) .all() ) states = { s.item_id: s for s in db.query(PlexState).filter( PlexState.user_id == user.id, PlexState.item_id.in_([e.id for e in eps] or [0]) ) } by_season: dict[int | None, list] = {} for e in eps: by_season.setdefault(e.season_id, []).append(_episode_card(e, states.get(e.id))) return { "show": { "id": sh.rating_key, "title": sh.title, "summary": sh.summary, "year": sh.year, "thumb": f"/api/plex/image/{sh.rating_key}", "art": f"/api/plex/image/{sh.rating_key}?variant=art", }, "seasons": [ { "id": se.rating_key, "season_number": se.season_number, "title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"), "thumb": f"/api/plex/image/{se.rating_key}", "episodes": by_season.get(se.id, []), } for se in seasons ], } def _image_key(db: Session, rating_key: str, variant: str) -> str | None: """The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys are proxyable — this is not an open image proxy.""" it = db.query(PlexItem).filter_by(rating_key=rating_key).first() if it is not None: return it.art_key if variant == "art" else it.thumb_key sh = db.query(PlexShow).filter_by(rating_key=rating_key).first() if sh is not None: return sh.art_key if variant == "art" else sh.thumb_key se = db.query(PlexSeason).filter_by(rating_key=rating_key).first() if se is not None: return se.thumb_key return None @router.get("/image/{rating_key}") def image( rating_key: str, variant: str = "thumb", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> Response: """Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication).""" key = _image_key(db, str(rating_key), "art" if variant == "art" else "thumb") if not key: raise HTTPException(status_code=404, detail="No image") try: with PlexClient(db) as plex: data, ct = plex.image_bytes(key) except PlexNotConfigured as e: raise HTTPException(status_code=400, detail=str(e)) except PlexError as e: raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}") return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})