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:
npeter83 2026-07-09 14:42:18 +02:00
parent 8675e24663
commit 04fb3fb16e
6 changed files with 278 additions and 0 deletions

View file

@ -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 PlexSiftlode 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