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
|
|
@ -202,6 +202,8 @@ class Settings(BaseSettings):
|
|||
plex_client_id: str = "siftlode-server"
|
||||
# How often the catalog sync job mirrors Plex metadata, in minutes.
|
||||
plex_sync_interval_min: int = 360
|
||||
# How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C).
|
||||
plex_watch_sync_interval_min: int = 30
|
||||
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
|
||||
# expensive; direct-serve (browser-compatible files) has no such limit.
|
||||
plex_max_transcodes: int = 1
|
||||
|
|
|
|||
|
|
@ -1123,6 +1123,37 @@ class PlexState(Base, UpdatedAtMixin):
|
|||
)
|
||||
|
||||
|
||||
class PlexLink(Base, UpdatedAtMixin):
|
||||
"""Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user).
|
||||
|
||||
For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`,
|
||||
`plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes
|
||||
the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to
|
||||
filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link
|
||||
their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the
|
||||
sync loop then iterates link rows uniformly.
|
||||
|
||||
`initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the
|
||||
high-water mark for the incremental Plex→Siftlode pass."""
|
||||
|
||||
__tablename__ = "plex_link"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted)
|
||||
plex_account_id: Mapped[int | None] = mapped_column(Integer)
|
||||
plex_username: Mapped[str | None] = mapped_column(String(255))
|
||||
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
initial_import_done: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
|
||||
users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a
|
||||
|
|
|
|||
124
backend/app/plex/watch_sync.py
Normal file
124
backend/app/plex/watch_sync.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Plex ↔ Siftlode watch-state sync.
|
||||
|
||||
Plex records watch state PER PLEX ACCOUNT; Siftlode records it per Siftlode user in `plex_states`.
|
||||
A `PlexLink` row maps one Siftlode user to a Plex account. For the owner (MVP) that account is the
|
||||
server admin (`uses_admin=True`), so the existing admin token already reads/writes the owner's state
|
||||
— no separate Plex login.
|
||||
|
||||
Phase A (this module today): the one-time **"Plex is master" import** — read the owner account's
|
||||
viewCount / viewOffset / lastViewedAt (present in the same cheap section listing the catalog mirror
|
||||
already pages) and upsert them into the owner's `plex_states`. Plex wins on overlap; states that
|
||||
exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection).
|
||||
|
||||
Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch
|
||||
history + onDeck, last-write-wins) build on this and land in later ships.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.models import PlexItem, PlexLink, PlexState
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.sync import (
|
||||
_TYPE_EPISODE,
|
||||
_TYPE_MOVIE,
|
||||
_enabled_section_keys,
|
||||
_epoch,
|
||||
_paginate,
|
||||
)
|
||||
|
||||
log = logging.getLogger("siftlode.plex.watch")
|
||||
|
||||
# A resume position below this many seconds isn't worth mirroring — matches the player's own
|
||||
# _PROGRESS_MIN_S (a stray few-second seek is not "in progress").
|
||||
_PROGRESS_MIN_S = 5
|
||||
|
||||
|
||||
def _plex_watch_to_state(
|
||||
view_count, view_offset_ms, last_viewed_at
|
||||
) -> tuple[str, int, datetime | None, datetime | None] | None:
|
||||
"""Translate a Plex leaf's per-account watch fields into a Siftlode state tuple
|
||||
``(status, position_seconds, watched_at, progress_updated_at)``, or None when Plex holds no
|
||||
meaningful watch signal for it (so the import skips it and never clears a Siftlode-only state).
|
||||
|
||||
In Siftlode's model (mirrors item_progress/item_state): a *watched* item is ``status="watched"``
|
||||
with position 0; an *in-progress* item keeps the default status and carries ``position_seconds``
|
||||
plus ``progress_updated_at``."""
|
||||
ts = _epoch(last_viewed_at)
|
||||
if view_count and int(view_count) > 0:
|
||||
return ("watched", 0, ts, ts)
|
||||
if view_offset_ms:
|
||||
pos = int(view_offset_ms) // 1000
|
||||
if pos >= _PROGRESS_MIN_S:
|
||||
return ("new", pos, None, ts)
|
||||
return None
|
||||
|
||||
|
||||
def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
|
||||
"""One-time "Plex is master" import of the linked user's Plex watch state into their
|
||||
`plex_states`. Idempotent (safe to re-run — Plex simply re-wins on the intersection). Only
|
||||
touches items Plex has a watch record for; Siftlode-only states are preserved."""
|
||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||
return {"skipped": "disabled"}
|
||||
|
||||
# rating_key -> plex_items.id for every mirrored playable leaf (movie/episode).
|
||||
item_id_by_rk: dict[str, int] = {
|
||||
rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)
|
||||
}
|
||||
# This user's existing states, by item — so we upsert rather than duplicate.
|
||||
existing: dict[int, PlexState] = {
|
||||
st.item_id: st
|
||||
for st in db.query(PlexState).filter_by(user_id=link.user_id)
|
||||
}
|
||||
stats = {"watched": 0, "in_progress": 0, "scanned": 0}
|
||||
wanted = _enabled_section_keys(db)
|
||||
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
for s in plex.sections():
|
||||
stype = s.get("type")
|
||||
if stype not in ("movie", "show"):
|
||||
continue
|
||||
key = str(s.get("key"))
|
||||
if wanted is not None and key not in wanted:
|
||||
continue
|
||||
item_type = _TYPE_MOVIE if stype == "movie" else _TYPE_EPISODE
|
||||
for meta in _paginate(plex, key, item_type):
|
||||
stats["scanned"] += 1
|
||||
item_id = item_id_by_rk.get(str(meta.get("ratingKey") or ""))
|
||||
if item_id is None:
|
||||
continue
|
||||
res = _plex_watch_to_state(
|
||||
meta.get("viewCount"),
|
||||
meta.get("viewOffset"),
|
||||
meta.get("lastViewedAt"),
|
||||
)
|
||||
if res is None:
|
||||
continue
|
||||
status, pos, watched_at, prog_at = res
|
||||
st = existing.get(item_id)
|
||||
if st is None:
|
||||
st = PlexState(user_id=link.user_id, item_id=item_id)
|
||||
db.add(st)
|
||||
existing[item_id] = st
|
||||
st.status = status
|
||||
st.position_seconds = pos
|
||||
st.watched_at = watched_at
|
||||
st.progress_updated_at = prog_at
|
||||
# This state now mirrors Plex — mark it so Phase B's push doesn't bounce it back.
|
||||
st.synced_to_plex = True
|
||||
stats["watched" if status == "watched" else "in_progress"] += 1
|
||||
link.initial_import_done = True
|
||||
link.last_watch_sync_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except PlexNotConfigured as e:
|
||||
return {"skipped": str(e)}
|
||||
except PlexError as e:
|
||||
db.rollback()
|
||||
log.warning("Plex watch import failed (user %s): %s", link.user_id, e)
|
||||
return {"error": str(e)}
|
||||
|
||||
log.info("Plex watch import (user %s): %s", link.user_id, stats)
|
||||
return stats
|
||||
|
|
@ -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 --------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
|
||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||
# NOTE: plex_watch_sync_interval_min (config.py default) is surfaced here in Phase C, when the
|
||||
# incremental watch-sync scheduler job that reads it lands.
|
||||
)
|
||||
|
||||
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue