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
50
backend/alembic/versions/0051_plex_link.py
Normal file
50
backend/alembic/versions/0051_plex_link.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""plex_link: per-user Plex account link for two-way watch-state sync
|
||||||
|
|
||||||
|
Phase A of the Plex ↔ Siftlode watch-state sync. One row per Siftlode user who syncs their watch
|
||||||
|
state with a Plex account. The owner MVP uses the server admin token (uses_admin=True, no personal
|
||||||
|
token); a personal token column is reserved for later friend-account linking (PIN OAuth). See the
|
||||||
|
PlexLink model docstring.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0051_plex_link"
|
||||||
|
down_revision: Union[str, None] = "0050_asset_poster"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"plex_link",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("uses_admin", sa.Boolean(), server_default="true", nullable=False),
|
||||||
|
sa.Column("plex_token_enc", sa.Text(), nullable=True),
|
||||||
|
sa.Column("plex_account_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("plex_username", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("sync_enabled", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
sa.Column("initial_import_done", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
sa.Column("last_watch_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("linked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", name="uq_plex_link_user"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plex_link_user_id", "plex_link", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plex_link_user_id", table_name="plex_link")
|
||||||
|
op.drop_table("plex_link")
|
||||||
|
|
@ -202,6 +202,8 @@ class Settings(BaseSettings):
|
||||||
plex_client_id: str = "siftlode-server"
|
plex_client_id: str = "siftlode-server"
|
||||||
# How often the catalog sync job mirrors Plex metadata, in minutes.
|
# How often the catalog sync job mirrors Plex metadata, in minutes.
|
||||||
plex_sync_interval_min: int = 360
|
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
|
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
|
||||||
# expensive; direct-serve (browser-compatible files) has no such limit.
|
# expensive; direct-serve (browser-compatible files) has no such limit.
|
||||||
plex_max_transcodes: int = 1
|
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):
|
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
|
"""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
|
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,
|
PlexCollection,
|
||||||
PlexItem,
|
PlexItem,
|
||||||
PlexLibrary,
|
PlexLibrary,
|
||||||
|
PlexLink,
|
||||||
PlexPlaylist,
|
PlexPlaylist,
|
||||||
PlexPlaylistItem,
|
PlexPlaylistItem,
|
||||||
PlexSeason,
|
PlexSeason,
|
||||||
|
|
@ -39,6 +40,7 @@ from app.models import (
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
from app.plex import stream as plex_stream
|
from app.plex import stream as plex_stream
|
||||||
from app.plex import sync as plex_sync
|
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.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.routes.admin import admin_user
|
from app.routes.admin import admin_user
|
||||||
from app.routes.feed import _to_tsquery_str
|
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)
|
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 --------------------------------------------------
|
# --- Read: libraries / browse / show / image --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
|
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
|
||||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
# (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),
|
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}
|
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue