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