"""Plex admin: connection test, catalog sync, and owner watch-link config.""" from typing import TYPE_CHECKING from fastapi import APIRouter, Depends from pydantic import BaseModel 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 User from app.plex import paths as plex_paths from app.plex import queries, service 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 if TYPE_CHECKING: from app.plex.models import PlexLink 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 = queries.sample_file_path(db) 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).""" return _link_dict(queries.watch_link(db, user)) 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 = service.set_watch_link(db, user, 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 = queries.watch_link(db, user) 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}