Files
siftlode/backend/app/routes/plex/admin.py
T
peter 7ae5c57b6b fix(i18n): every refusal the API answers is structured and said in the reader's language
~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
2026-09-19 07:34:17 +02:00

164 lines
6.4 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 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 {**_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 {**_link_dict(link), "import": result}