Mechanical 1.x->2.0 conversion of the self-contained plex modules, behaviour-
equivalent (the characterization net + existing plex tests stay green, 39
passed): app/plex/{sync(15),watch_sync(9),people(1),imgcache(4)} and
app/routes/plex/{admin(4),playback(8),_shared(1)}. Query builders become
select(); .first()/.all()->execute().scalars(); a bulk .update() ->
execute(update()); a single-column .first() -> db.scalar().
browse.py (61) + rollup.agg_subquery (the atomic unit with unified_library)
remain for wave 2.
173 lines
6.3 KiB
Python
173 lines
6.3 KiB
Python
"""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 import select
|
|
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."""
|
|
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 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 HTTPException(status_code=409, detail="The Plex module is 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 {**_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 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}
|