"""Plex admin: connection test, catalog sync, and owner watch-link config.""" from datetime import datetime, timezone from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session from app import logs, sysconfig from app.auth import admin_user, current_user from app.db import get_db from app.models import ( PlexItem, PlexLink, User, ) from app.plex import paths as plex_paths from app.plex import sync as plex_sync from app.plex import watch_sync as plex_watch from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.utils import iso from app.refusal import refused log = logs.get_logger(__name__) router = APIRouter(prefix="/api/plex", tags=["plex"]) @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). Also probes ONE real media file through the local mount, so a missing bind-mount / wrong path-map is caught here rather than only surfacing as a dead player later.""" try: with PlexClient(db) as plex: info = plex.server_info() sections = plex.sections() media_check = _media_check(db, plex, sections) except PlexNotConfigured: raise refused(400, "plex_not_configured") from None except PlexError as e: # What the upstream said is for the log, not the toast: it can quote the server URL back. log.warning( "the Plex connection test failed", extra=logs.event("plex.admin.connection_failed", reason=type(e).__name__, error=str(e)), ) raise refused(422, "plex_unreachable") from None 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") ], "media_check": media_check, } def _sample_plex_path(db: Session, plex: PlexClient, sections: list[dict]) -> str | None: """A real Plex-side media file path to validate the mount + path-map against. Prefers the already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled section straight from Plex.""" fp = db.scalar(select(PlexItem.file_path).where(PlexItem.file_path.isnot(None))) if fp: return fp wanted = plex_sync._enabled_section_keys(db) for s in sections: if s.get("type") != "movie": continue key = str(s.get("key")) if wanted is not None and key not in wanted: continue items, _ = plex.section_items(key, 1, 0, 1) if items: fp = plex_sync._media_facts(items[0]).get("file_path") if fp: return fp return None def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict: """Resolve a sample Plex file path to its local mount path and confirm it's readable, so the admin sees at Test time whether the physical media is actually reachable (bind-mount + path-map correct). ``checked=False`` when no sample is available (empty library).""" plex_path = _sample_plex_path(db, plex, sections) if not plex_path: return {"checked": False} local_path = plex_paths.map_path(db, plex_path) ok = plex_paths.local_media_path(db, plex_path) is not None return {"checked": True, "ok": ok, "plex_path": plex_path, "local_path": local_path} @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 {"ok": True, **plex_sync.sync(db)} def _link_dict(link: PlexLink | None) -> dict: return { "linked": link is not None, "sync_enabled": bool(link and link.sync_enabled), "uses_admin": bool(link and link.uses_admin), "initial_import_done": bool(link and link.initial_import_done), "last_watch_sync_at": iso(link.last_watch_sync_at) if link else None, } @router.get("/watch/link") def watch_link_status(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """This user's Plex watch-sync link status (drives the Settings toggle).""" link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first() return _link_dict(link) class WatchLinkIn(BaseModel): enabled: bool = False @router.post("/watch/link") def watch_link_set( body: WatchLinkIn, user: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Admin: turn two-way Plex watch-sync on/off for THIS (owner) account. The link uses the server admin token, so no separate Plex login. Enabling it for the first time runs the one-time "Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode.""" if not sysconfig.get_bool(db, "plex_enabled"): raise refused(409, "plex_disabled") enabled = body.enabled link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first() if link is None: link = PlexLink(user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)) db.add(link) link.sync_enabled = enabled db.commit() result: dict = {} if enabled and not link.initial_import_done: result = plex_watch.import_owner_watch_state(db, link) return {"ok": True, **_link_dict(link), "import": result or None} @router.post("/watch/import") def watch_import_now(user: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Admin: (re-)run the "Plex is master" import for this account — Plex re-wins on the intersection; Siftlode-only states are preserved. Idempotent.""" link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first() if link is None or not link.sync_enabled: raise refused(409, "watch_sync_off") result = plex_watch.import_owner_watch_state(db, link) return {"ok": True, **_link_dict(link), "import": result}