Files
siftlode/backend/app/routes/plex/admin.py
T
peter 531eb2eef1 refactor(plex): playlists, playback, Discover and admin through the unit; the plex package is fenced
The last Plex HTTP files do no database work of their own, so ALLOWED is empty: no route builds
SQL, touches the session or a unit's table. Discover's rails move whole into plex/discover.py; a
user's playlists, watch-state writes (the race-safe finish, an early checkpoint, a status, a
season or show at once) and the watch-sync link go through plex/queries.py and plex/service.py.

The plex package joins FENCED: it reads no other unit's tables, says whose a row is through
userscope (the rollup and the watch sync's 8 inline filters moved to owned_by), and commits only in
its named jobs — the library sync, TMDB enrichment, the watch sync and its background pushes.
A playlist's has-item probe keeps the plain key lookup, so a NUL in it is still the 422 a test pins.

Pins 178 -> 0; basedpyright baseline 118 -> 106.
2026-10-19 22:42:46 +02:00

158 lines
6.1 KiB
Python

"""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}