Files
siftlode/backend/app/routes/plex/admin.py
T
peter 0ca0cac534 fix(api): a waiting reorder deals from the fresh order; the commit check sees staged writes
Round 2 of the deep review, on round 1's own fixes:
- `ORDER BY position … FOR UPDATE` sorts before it locks, so the reorder that waited got the
  fresh rows in its STALE order and dealt the pins it left out (a tab with Plex off) back to their
  old slots. It locks in id order — one global order, so two reorders cannot deadlock — and sorts
  the fresh rows itself. The race test now sends that partial list and fails unless tab B really
  blocked.
- The request check saw only writes the handler flushed: a `db.delete` left staged, or a flush
  followed by a released savepoint, passed. Staged changes count at the end of the request, and
  only the ROOT transaction's end clears it (a savepoint test drives the dependency directly).
- The `ok` test exempted every call as a serializer, hiding four domain REPORTS (YouTube sync,
  revert, push; Plex sync) without `ok`. They are spread into `{"ok": True, …}`; the exemption is a
  named list of real serializers.
2026-09-19 19:30:27 +02:00

164 lines
6.5 KiB
Python

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