feat(plex): Phase A — one-time Plex→Siftlode watch-state import
Plex records watch state per Plex account; Siftlode per user in plex_states. A new plex_link table maps a Siftlode user to a Plex account; for the owner (MVP) the row uses the server admin token (uses_admin=True), so no separate Plex login. app/plex/watch_sync.py reads the owner account's viewCount/viewOffset/lastViewedAt (already present in the catalog mirror's section listing) and upserts them into the owner's plex_states — 'Plex is master' on this first import, but only where Plex has a watch record (Siftlode-only states are preserved; union on the intersection). Idempotent. Admin routes: GET/POST /api/plex/watch/link (status + enable/disable; first enable runs the import) and POST /api/plex/watch/import (re-run). Migration 0051_plex_link. Two-way push (Phase B) and the incremental Plex→Siftlode reconcile (Phase C) build on this in later ships.
This commit is contained in:
parent
8675e24663
commit
04fb3fb16e
6 changed files with 278 additions and 0 deletions
|
|
@ -29,6 +29,7 @@ from app.models import (
|
|||
PlexCollection,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexLink,
|
||||
PlexPlaylist,
|
||||
PlexPlaylistItem,
|
||||
PlexSeason,
|
||||
|
|
@ -39,6 +40,7 @@ from app.models import (
|
|||
from app.plex import paths as plex_paths
|
||||
from app.plex import stream as plex_stream
|
||||
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.routes.feed import _to_tsquery_str
|
||||
|
|
@ -122,6 +124,73 @@ def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -
|
|||
return plex_sync.sync(db)
|
||||
|
||||
|
||||
# --- Watch-state sync (Plex ↔ Siftlode) -------------------------------------------------------
|
||||
# Phase A: the owner links their Siftlode account to the Plex admin account and does a one-time
|
||||
# "Plex is master" import. Two-way push/pull lands in later phases. Admin-only for now because it
|
||||
# rides the server admin token (which reads/writes the OWNER Plex account's state).
|
||||
|
||||
|
||||
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": (
|
||||
link.last_watch_sync_at.isoformat()
|
||||
if link and link.last_watch_sync_at
|
||||
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.query(PlexLink).filter_by(user_id=user.id).first()
|
||||
return _link_dict(link)
|
||||
|
||||
|
||||
@router.post("/watch/link")
|
||||
def watch_link_set(
|
||||
payload: dict,
|
||||
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 = bool(payload.get("enabled"))
|
||||
link = db.query(PlexLink).filter_by(user_id=user.id).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.query(PlexLink).filter_by(user_id=user.id).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}
|
||||
|
||||
|
||||
# --- Read: libraries / browse / show / image --------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue