"""Plex admin: connection test, catalog sync, and owner watch-link config.""" from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from app import sysconfig from app.auth import 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.routes.admin import admin_user from app.utils import iso 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 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") ], "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.""" row = db.query(PlexItem.file_path).filter(PlexItem.file_path.isnot(None)).first() if row and row[0]: return row[0] 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 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.query(PlexLink).filter_by(user_id=user.id).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 HTTPException(status_code=409, detail="The Plex module is disabled.") enabled = body.enabled link = db.query(PlexLink).filter_by(user_id=user.id).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 {**_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.query(PlexLink).filter_by(user_id=user.id).first() if link is None or not link.sync_enabled: raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.") result = plex_watch.import_owner_watch_state(db, link) return {**_link_dict(link), "import": result}