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.
164 lines
6.5 KiB
Python
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}
|