Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-10 00:50:10 +02:00
commit e12da81082
12 changed files with 530 additions and 59 deletions

View file

@ -1 +1 @@
0.34.2 0.35.0

View file

@ -202,8 +202,13 @@ 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). # How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C):
# a cheap history + on-deck pull plus a re-push of any states that failed their immediate push.
plex_watch_sync_interval_min: int = 30 plex_watch_sync_interval_min: int = 30
# How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan
# that catches what the incremental history feed can't — notably an un-watch done on the Plex
# side — so it runs far less often (daily) than the incremental pass.
plex_watch_reconcile_interval_min: int = 1440
# 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

View file

@ -166,6 +166,73 @@ class PlexClient:
items = mc.get("Metadata", []) or [] items = mc.get("Metadata", []) or []
return items[0] if items else None return items[0] if items else None
# --- Watch-state write-back (P5 Phase B — Siftlode→Plex push; acts on the token's account) ---
_LIBRARY_IDENTIFIER = "com.plexapp.plugins.library"
def scrobble(self, rating_key: str) -> None:
"""Mark the item *watched* on the token's Plex account (viewCount++, resume cleared)."""
self._write(
"GET",
f"/:/scrobble?identifier={self._LIBRARY_IDENTIFIER}"
f"&key={quote(str(rating_key), safe='')}",
)
def unscrobble(self, rating_key: str) -> None:
"""Mark the item *unwatched* on the token's Plex account (clears viewCount)."""
self._write(
"GET",
f"/:/unscrobble?identifier={self._LIBRARY_IDENTIFIER}"
f"&key={quote(str(rating_key), safe='')}",
)
def set_timeline(
self, rating_key: str, time_ms: int, duration_ms: int, state: str = "stopped"
) -> None:
"""Report a resume position (viewOffset) for the item on the token's account. `state` is a
Plex timeline state (playing|paused|stopped); we push the settled position as ``stopped``."""
key = quote(f"/library/metadata/{rating_key}", safe="")
self._write(
"GET",
f"/:/timeline?identifier={self._LIBRARY_IDENTIFIER}"
f"&ratingKey={quote(str(rating_key), safe='')}&key={key}"
f"&state={state}&time={int(time_ms)}&duration={int(duration_ms)}",
)
# --- Watch-state read for incremental sync (P5 Phase C) ---
def accounts(self) -> list[dict]:
"""Server accounts (the owner + any managed/home users): id, name. Used to resolve the
owner's accountID so the shared watch-history feed can be filtered to just their views."""
return self._get("/accounts").get("Account", []) or []
def watch_history(
self, min_viewed_at: int = 0, account_id: int | None = None, size: int = 500
) -> list[dict]:
"""The most recent watch-history rows (ratingKey / viewedAt / accountID / type), newest
first works without Plex Pass. Filtered client-side to `account_id` and to rows at/after
`min_viewed_at` (epoch seconds): the efficient "what changed since T" feed. Sorted desc, so
we stop at the first row older than the cutoff."""
mc = self._get(
"/status/sessions/history/all",
params={
"sort": "viewedAt:desc",
"X-Plex-Container-Start": 0,
"X-Plex-Container-Size": size,
},
)
out = []
for r in mc.get("Metadata", []) or []:
if int(r.get("viewedAt") or 0) < min_viewed_at:
break
if account_id is not None and int(r.get("accountID") or -1) != account_id:
continue
out.append(r)
return out
def on_deck(self) -> list[dict]:
"""The token account's Continue-Watching list (ratingKey / viewOffset / lastViewedAt /
type) the incremental source for resume-position changes made on the Plex side."""
return self._get("/library/onDeck").get("Metadata", []) or []
def raw_get(self, path: str) -> bytes: def raw_get(self, path: str) -> bytes:
"""Raw bytes of an arbitrary Plex resource path (e.g. an external subtitle stream `key` like """Raw bytes of an arbitrary Plex resource path (e.g. an external subtitle stream `key` like
``/library/streams/553184``). Keeps the admin token server-side.""" ``/library/streams/553184``). Keeps the admin token server-side."""

View file

@ -14,13 +14,14 @@ Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→
history + onDeck, last-write-wins) build on this and land in later ships. history + onDeck, last-write-wins) build on this and land in later ships.
""" """
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
from app.db import SessionLocal
from app.models import PlexItem, PlexLink, PlexState from app.models import PlexItem, PlexLink, PlexState
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.sync import ( from app.plex.sync import (
@ -58,6 +59,39 @@ def _plex_watch_to_state(
return None return None
def _scan_plex_states(
db: Session, plex: PlexClient
) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int]:
"""Scan the enabled movie/show sections and return ``{item_id: (status, pos, watched_at,
prog_at)}`` for every mirrored leaf Plex holds a watch signal for, plus the scanned-leaf count.
Shared by the one-time import and the full reconcile (both need Plex's whole current picture)."""
item_id_by_rk: dict[str, int] = {
rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)
}
wanted = _enabled_section_keys(db)
scanned = 0
out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {}
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):
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 not None:
out[item_id] = res
return out, scanned
def import_owner_watch_state(db: Session, link: PlexLink) -> dict: 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 """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 `plex_states`. Idempotent (safe to re-run Plex simply re-wins on the intersection). Only
@ -65,41 +99,17 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
if not sysconfig.get_bool(db, "plex_enabled"): if not sysconfig.get_bool(db, "plex_enabled"):
return {"skipped": "disabled"} 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)
}
stats = {"watched": 0, "in_progress": 0, "scanned": 0} stats = {"watched": 0, "in_progress": 0, "scanned": 0}
wanted = _enabled_section_keys(db)
# Accumulate one row per item (keyed by item_id → inherently de-duped) then bulk-UPSERT. An upsert
# is idempotent AND concurrency-safe: a repeated/overlapping enable (the toggle firing twice while
# the multi-second import runs) can't raise a duplicate-key error — the conflict just re-updates.
rows_by_item: dict[int, dict] = {}
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
for s in plex.sections(): scanned_states, stats["scanned"] = _scan_plex_states(db, plex)
stype = s.get("type") # One row per item (keyed by item_id → inherently de-duped) then bulk-UPSERT. An upsert is
if stype not in ("movie", "show"): # idempotent AND concurrency-safe: a repeated/overlapping enable (the toggle firing twice
continue # while the multi-second import runs) can't raise a duplicate-key error — it re-updates.
key = str(s.get("key")) rows = []
if wanted is not None and key not in wanted: for item_id, (status, pos, watched_at, prog_at) in scanned_states.items():
continue rows.append(
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
rows_by_item[item_id] = {
"user_id": link.user_id, "user_id": link.user_id,
"item_id": item_id, "item_id": item_id,
"status": status, "status": status,
@ -109,11 +119,11 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
# Mirrors Plex now — so Phase B's push doesn't bounce it straight back. # Mirrors Plex now — so Phase B's push doesn't bounce it straight back.
"synced_to_plex": True, "synced_to_plex": True,
} }
stats["watched" if status == "watched" else "in_progress"] += 1 )
stats["watched" if status == "watched" else "in_progress"] += 1
# Bulk upsert in chunks. Plex wins on the intersection; Siftlode-only states (item_ids not # Bulk upsert in chunks. Plex wins on the intersection; Siftlode-only states (item_ids not
# in rows_by_item) are never referenced, so they're preserved untouched (union). # scanned) are never referenced, so they're preserved untouched (union).
rows = list(rows_by_item.values())
for i in range(0, len(rows), 1000): for i in range(0, len(rows), 1000):
stmt = pg_insert(PlexState).values(rows[i : i + 1000]) stmt = pg_insert(PlexState).values(rows[i : i + 1000])
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
@ -140,3 +150,268 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
log.info("Plex watch import (user %s): %s", link.user_id, stats) log.info("Plex watch import (user %s): %s", link.user_id, stats)
return stats return stats
# --- Phase B: Siftlode → Plex push -----------------------------------------------------------------
def link_for_push(db: Session, user_id: int) -> PlexLink | None:
"""The active owner watch-push link for this user, or None when push is off / not the owner.
Push is gated on `initial_import_done` so we never push a half-known local state over Plex's
authoritative one before the one-time "Plex is master" import has reconciled them."""
link = db.query(PlexLink).filter_by(user_id=user_id).first()
if link and link.uses_admin and link.sync_enabled and link.initial_import_done:
return link
return None
def push_state_to_plex(
user_id: int,
item_id: int,
rating_key: str,
action: str,
time_ms: int = 0,
duration_ms: int = 0,
) -> None:
"""Best-effort Siftlode→Plex push, run in a FastAPI BackgroundTask (its OWN DB session — the
request's session is already closed by the time this runs). Never raises: Plex being down or slow
must not break the user's action. On success it flags `synced_to_plex` so Phase C's incremental
pull won't bounce the change straight back; on failure the row stays dirty (synced_to_plex=False)
for a later retry/reconcile.
`action` is one of ``watched`` (scrobble), ``unwatched`` (unscrobble), ``resume`` (timeline)."""
with SessionLocal() as db:
if link_for_push(db, user_id) is None:
return
try:
with PlexClient(db) as plex:
if action == "watched":
plex.scrobble(rating_key)
elif action == "unwatched":
plex.unscrobble(rating_key)
elif action == "resume":
plex.set_timeline(rating_key, time_ms, duration_ms)
else:
return
except PlexError as e:
log.warning(
"Plex watch push failed (user %s, item %s, %s): %s", user_id, item_id, action, e
)
return
# Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be
# nothing to flag; that's fine (unscrobble still happened).
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
if st is not None:
st.synced_to_plex = True
db.commit()
# --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just
# pushed (Plex stamps viewedAt a beat after our watched_at) must not read as "newer on Plex" and get
# pulled back with a different shape. Genuine Plex-side edits are minutes from our writes — far
# outside this — so the window only absorbs skew, never real changes.
_LWW_TOLERANCE_S = 90
def _active_sync_links(db: Session) -> list[PlexLink]:
"""Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so
P5b friend links slot in later without touching this loop."""
return (
db.query(PlexLink)
.filter_by(uses_admin=True, sync_enabled=True, initial_import_done=True)
.all()
)
def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
"""The owner's Plex accountID, cached on the link. The server owner is accountID 1 on their own
server; we confirm + grab their username from /accounts, falling back to 1 if unavailable."""
if link.plex_account_id:
return link.plex_account_id
acct_id, username = 1, None
try:
for a in plex.accounts():
if int(a.get("id") or -1) == 1:
username = a.get("name")
break
except PlexError:
pass
link.plex_account_id = acct_id
if username:
link.plex_username = username
db.commit()
return acct_id
def _sift_ts(st: PlexState) -> datetime | None:
"""The freshest Siftlode-side timestamp on a row, for last-write-wins."""
return max([t for t in (st.watched_at, st.progress_updated_at) if t], default=None)
def _same_state(st: PlexState | None, status: str, pos: int) -> bool:
"""Whether a row already matches a target (status, pos) — the core ping-pong guard: when nothing
would change we skip the write entirely (no row churn, no timestamp bump, no re-pull)."""
if st is None:
return False
if status == "watched":
return st.status == "watched"
return st.status != "watched" and abs((st.position_seconds or 0) - pos) <= _PROGRESS_MIN_S
def _pull_apply(
db: Session, user_id: int, item_id: int, status: str, pos: int, plex_ts: datetime | None
) -> str | None:
"""Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns
"watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the
full reconcile's job) — this only pulls in watched/resume."""
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
if _same_state(st, status, pos):
if st is not None and not st.synced_to_plex:
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
return None
# Conflict: keep Siftlode when it's newer than Plex (plus skew slack).
sts = _sift_ts(st) if st else None
if sts and plex_ts and plex_ts <= sts + timedelta(seconds=_LWW_TOLERANCE_S):
return None
if st is None:
st = PlexState(user_id=user_id, item_id=item_id)
db.add(st)
if status == "watched":
st.status, st.position_seconds, st.watched_at, st.progress_updated_at = "watched", 0, plex_ts, plex_ts
else:
st.status, st.position_seconds, st.progress_updated_at = "new", pos, plex_ts
st.synced_to_plex = True
return "watched" if status == "watched" else "in_progress"
def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
"""Belt-and-suspenders: push local states that never reached Plex (synced_to_plex=False) — e.g.
an immediate Phase B push that failed while Plex was down. `hidden` is excluded (Siftlode-only,
never goes to Plex). Returns the count re-pushed."""
rk_by_id = dict(db.query(PlexItem.id, PlexItem.rating_key))
dirty = (
db.query(PlexState)
.filter(
PlexState.user_id == link.user_id,
PlexState.synced_to_plex.is_(False),
PlexState.status != "hidden",
)
.all()
)
n = 0
for st in dirty:
rk = rk_by_id.get(st.item_id)
if rk is None:
continue
try:
if st.status == "watched":
plex.scrobble(rk)
elif st.position_seconds and st.position_seconds >= _PROGRESS_MIN_S:
dur = int((plex.metadata(rk) or {}).get("duration") or 0)
plex.set_timeline(rk, st.position_seconds * 1000, dur)
else:
plex.unscrobble(rk)
st.synced_to_plex = True
n += 1
except PlexError as e:
log.warning(
"Plex dirty re-push failed (user %s, item %s): %s", link.user_id, st.item_id, e
)
return n
def run_plex_watch_sync(db: Session) -> dict:
"""Scheduler job (default 30 min) — the cheap incremental pass. Pulls Plex-side changes since the
high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode under
last-write-wins, then re-pushes any still-unsynced local states. Does NOT propagate un-watches
(the history feed only carries positive views) that's `run_plex_watch_reconcile`'s job."""
if not sysconfig.get_bool(db, "plex_enabled"):
return {"skipped": "disabled"}
total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0}
for link in _active_sync_links(db):
total["links"] += 1
try:
with PlexClient(db) as plex:
acct = _resolve_account_id(db, plex, link)
since = int(link.last_watch_sync_at.timestamp()) if link.last_watch_sync_at else 0
now = datetime.now(timezone.utc)
rk_to_id = {rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)}
for row in plex.watch_history(min_viewed_at=since, account_id=acct):
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
if iid is None:
continue
ch = _pull_apply(db, link.user_id, iid, "watched", 0, _epoch(row.get("viewedAt")))
if ch:
total[ch] += 1
for row in plex.on_deck():
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
if iid is None:
continue
off = int(row.get("viewOffset") or 0) // 1000
if off < _PROGRESS_MIN_S:
continue
ch = _pull_apply(db, link.user_id, iid, "new", off, _epoch(row.get("lastViewedAt")))
if ch:
total[ch] += 1
total["repushed"] += _repush_dirty(db, plex, link)
link.last_watch_sync_at = now
db.commit()
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e:
db.rollback()
log.warning("Plex watch sync failed (user %s): %s", link.user_id, e)
log.info("Plex watch sync: %s", total)
return total
def run_plex_watch_reconcile(db: Session) -> dict:
"""Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and,
per item, uses `synced_to_plex` to settle what the incremental feed can't:
- Plex has a state, Siftlode differs last-write-wins (Plex usually the newer here).
- Siftlode has a state, Plex has NO record:
· synced_to_plex=True Plex un-watched it since our last sync clear it (un-watch).
· synced_to_plex=False a local change that never reached Plex re-push it (via
`_repush_dirty`), never dropped.
- `hidden` rows are Siftlode-only and never cleared.
Union-preserving: a never-synced Siftlode-only state is pushed up, not lost."""
if not sysconfig.get_bool(db, "plex_enabled"):
return {"skipped": "disabled"}
total = {"links": 0, "applied": 0, "cleared": 0, "repushed": 0, "scanned": 0}
for link in _active_sync_links(db):
total["links"] += 1
try:
with PlexClient(db) as plex:
_resolve_account_id(db, plex, link)
plex_states, scanned = _scan_plex_states(db, plex)
total["scanned"] += scanned
for item_id, (status, pos, watched_at, prog_at) in plex_states.items():
ch = _pull_apply(db, link.user_id, item_id, status, pos, watched_at or prog_at)
if ch:
total["applied"] += 1
# Siftlode rows Plex no longer has a record for: clear the ones that were mirrored
# (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below).
for st in db.query(PlexState).filter_by(user_id=link.user_id).all():
if st.item_id in plex_states or st.status == "hidden":
continue
if st.synced_to_plex:
db.delete(st)
total["cleared"] += 1
total["repushed"] += _repush_dirty(db, plex, link)
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 reconcile failed (user %s): %s", link.user_id, e)
log.info("Plex watch reconcile: %s", total)
return total

View file

@ -16,7 +16,7 @@ from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import and_, func, or_, text from sqlalchemy import and_, func, or_, text
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
@ -1486,22 +1486,49 @@ def item_detail(
} }
def _push_watch(
background: BackgroundTasks,
db: Session,
user_id: int,
item_id: int,
rating_key: str,
action: str,
time_ms: int = 0,
duration_ms: int = 0,
) -> None:
"""Schedule a best-effort Siftlode→Plex watch push, but only for a user whose owner link has
push enabled non-owner users never touch Plex. Guarding here (with the request session) avoids
spinning up a throwaway background session on every state change for the common no-link case."""
if plex_watch.link_for_push(db, user_id) is None:
return
background.add_task(
plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms
)
@router.post("/item/{rating_key}/progress") @router.post("/item/{rating_key}/progress")
def item_progress( def item_progress(
rating_key: str, rating_key: str,
payload: dict, payload: dict,
background: BackgroundTasks,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Checkpoint the resume position while playing (near the end → mark watched + clear it).""" """Checkpoint the resume position while playing (near the end → mark watched + clear it).
`final` (sent on the pause/pagehide/unmount flush, not the periodic 10s checkpoint) is what
debounces the Plex resume push: we mirror the settled position to Plex only on a final flush, so
a single watch doesn't spray `/:/timeline` calls at the server every 10 seconds."""
it = _item_or_404(db, rating_key) it = _item_or_404(db, rating_key)
try: try:
position = max(0, int(payload.get("position_seconds") or 0)) position = max(0, int(payload.get("position_seconds") or 0))
duration = int(payload.get("duration_seconds") or 0) duration = int(payload.get("duration_seconds") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="position_seconds must be an integer") raise HTTPException(status_code=400, detail="position_seconds must be an integer")
final = bool(payload.get("final"))
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
was_watched = st is not None and st.status == "watched"
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
if near_end: if near_end:
@ -1512,7 +1539,12 @@ def item_progress(
st.watched_at = now st.watched_at = now
st.position_seconds = 0 st.position_seconds = 0
st.progress_updated_at = now st.progress_updated_at = now
st.synced_to_plex = False
db.commit() db.commit()
# Immediate on the watched transition; skip a repeat scrobble if it was already watched
# (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again).
if not was_watched:
_push_watch(background, db, user.id, it.id, rating_key, "watched")
return {"status": "watched", "position_seconds": 0} return {"status": "watched", "position_seconds": 0}
if position < _PROGRESS_MIN_S: if position < _PROGRESS_MIN_S:
@ -1529,7 +1561,13 @@ def item_progress(
db.add(st) db.add(st)
st.position_seconds = position st.position_seconds = position
st.progress_updated_at = now st.progress_updated_at = now
if final:
st.synced_to_plex = False
db.commit() db.commit()
if final:
_push_watch(
background, db, user.id, it.id, rating_key, "resume", position * 1000, duration * 1000
)
return {"position_seconds": position} return {"position_seconds": position}
@ -1537,19 +1575,25 @@ def item_progress(
def item_state( def item_state(
rating_key: str, rating_key: str,
payload: dict, payload: dict,
background: BackgroundTasks,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Set the per-user watch status (new|watched|hidden).""" """Set the per-user watch status (new|watched|hidden). watched→scrobble / →new→unscrobble push
to a linked Plex account immediately; `hidden` is a Siftlode-only concept with no Plex analogue,
so it never touches Plex."""
it = _item_or_404(db, rating_key) it = _item_or_404(db, rating_key)
status = payload.get("status") status = payload.get("status")
if status not in ("new", "watched", "hidden"): if status not in ("new", "watched", "hidden"):
raise HTTPException(status_code=400, detail="Invalid status") raise HTTPException(status_code=400, detail="Invalid status")
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
prev_status = st.status if st is not None else "new"
if status == "new": if status == "new":
if st is not None: if st is not None:
db.delete(st) db.delete(st)
db.commit() db.commit()
if prev_status != "new": # something → unwatched: mirror the un-mark to Plex
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
return {"status": "new"} return {"status": "new"}
if st is None: if st is None:
st = PlexState(user_id=user.id, item_id=it.id) st = PlexState(user_id=user.id, item_id=it.id)
@ -1558,7 +1602,10 @@ def item_state(
if status == "watched": if status == "watched":
st.watched_at = datetime.now(timezone.utc) st.watched_at = datetime.now(timezone.utc)
st.position_seconds = 0 st.position_seconds = 0
st.synced_to_plex = False
db.commit() db.commit()
if status == "watched" and prev_status != "watched":
_push_watch(background, db, user.id, it.id, rating_key, "watched")
return {"status": status} return {"status": status}

View file

@ -15,6 +15,7 @@ from app.db import SessionLocal
from app.downloads.gc import run_download_gc from app.downloads.gc import run_download_gc
from app.models import SchedulerSetting from app.models import SchedulerSetting
from app.plex.sync import sync as run_plex_sync from app.plex.sync import sync as run_plex_sync
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
from app.notifications import create_notification from app.notifications import create_notification
from app.state import is_sync_paused from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all from app.sync.autotag import run_autotag_all
@ -63,6 +64,8 @@ JOB_INTERVALS: dict[str, int] = {
"explore_cleanup": settings.explore_cleanup_minutes, "explore_cleanup": settings.explore_cleanup_minutes,
"download_gc": settings.download_gc_minutes, "download_gc": settings.download_gc_minutes,
"plex_sync": settings.plex_sync_interval_min, "plex_sync": settings.plex_sync_interval_min,
"plex_watch_sync": settings.plex_watch_sync_interval_min,
"plex_watch_reconcile": settings.plex_watch_reconcile_interval_min,
} }
# Sane bounds for an admin-set interval (minutes). # Sane bounds for an admin-set interval (minutes).
@ -292,6 +295,18 @@ def _plex_sync_job() -> None:
_job("plex_sync", run_plex_sync) _job("plex_sync", run_plex_sync)
def _plex_watch_sync_job() -> None:
# Incremental Plex→Siftlode watch-state pull (history + on-deck) + re-push of unsynced local
# states. Cheap; a no-op when Plex is disabled or no owner link has sync enabled.
_job("plex_watch_sync", run_plex_watch_sync)
def _plex_watch_reconcile_job() -> None:
# Full watch-state reconcile (whole-section rescan) — catches un-watches the incremental feed
# can't. Heavier, so it runs far less often (daily default).
_job("plex_watch_reconcile", run_plex_watch_reconcile)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one, # job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now"). # shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = { JOB_FUNCS: dict[str, Callable[[], None]] = {
@ -307,6 +322,8 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"explore_cleanup": _explore_cleanup_job, "explore_cleanup": _explore_cleanup_job,
"download_gc": _download_gc_job, "download_gc": _download_gc_job,
"plex_sync": _plex_sync_job, "plex_sync": _plex_sync_job,
"plex_watch_sync": _plex_watch_sync_job,
"plex_watch_reconcile": _plex_watch_reconcile_job,
} }

View file

@ -452,23 +452,31 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const absRef = useRef(0); const absRef = useRef(0);
absRef.current = abs; absRef.current = abs;
useEffect(() => { useEffect(() => {
const flush = () => { // `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
const flush = (final = false) => {
if (absRef.current > 0 && durationRef.current > 0) if (absRef.current > 0 && durationRef.current > 0)
api.plexProgress(id, Math.floor(absRef.current), durationRef.current).catch(() => {}); api.plexProgress(id, Math.floor(absRef.current), durationRef.current, final).catch(() => {});
}; };
const iv = window.setInterval(flush, 10000); const iv = window.setInterval(flush, 10000);
// Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
const onPauseFlush = () => flush(true);
const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush);
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload, // Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a // so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
// keepalive beacon so the POST survives the unload. // keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
const onHide = () => { const onHide = () => {
if (absRef.current > 0 && durationRef.current > 0) if (absRef.current > 0 && durationRef.current > 0)
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current); api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
}; };
window.addEventListener("pagehide", onHide); window.addEventListener("pagehide", onHide);
return () => { return () => {
window.clearInterval(iv); window.clearInterval(iv);
window.removeEventListener("pagehide", onHide); window.removeEventListener("pagehide", onHide);
flush(); video?.removeEventListener("pause", onPauseFlush);
flush(true);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in // Back (unmount) persists the position server-side via flush(), but reopening the same item in
// the SAME session read react-query's CACHED detail — whose position_seconds predated this // the SAME session read react-query's CACHED detail — whose position_seconds predated this
// watch — so the resume landed at the old spot (the progress GET could also race the POST). // watch — so the resume landed at the old spot (the progress GET could also race the POST).
@ -558,6 +566,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const go = useCallback( const go = useCallback(
(otherId: string | null | undefined) => { (otherId: string | null | undefined) => {
if (otherId) { if (otherId) {
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
try {
videoRef.current?.pause();
} catch {
/* ignore */
}
setAbs(0); setAbs(0);
setId(otherId); setId(otherId);
} }
@ -702,6 +719,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
const onEnded = () => { const onEnded = () => {
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {}); api.plexSetState(id, "watched").catch(() => {});
if (nextId) go(nextId); if (nextId) go(nextId);
}; };
@ -837,10 +857,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const doSkip = useCallback( const doSkip = useCallback(
(m: PlexMarker) => { (m: PlexMarker) => {
setSkipProgress(null); setSkipProgress(null);
if (m.type === "credits" && nextId) go(nextId); if (m.type === "credits" && nextId) {
else seekTo(m.end_s); // Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
// (the current id, captured here — not the reactive id, which go() is about to change).
api.plexSetState(id, "watched").catch(() => {});
go(nextId);
} else seekTo(m.end_s);
}, },
[go, nextId, seekTo], [id, go, nextId, seekTo],
); );
const cancelAutoSkip = useCallback(() => { const cancelAutoSkip = useCallback(() => {
const m = activeMarkerRef.current; const m = activeMarkerRef.current;

View file

@ -69,7 +69,9 @@
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.", "maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.",
"explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.", "explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.",
"download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.", "download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.",
"plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync." "plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync.",
"plex_watch_sync": "Holt kürzliche Plex-Wiedergabeänderungen (beendete Episoden, Fortsetzungspositionen) nach Siftlode und schiebt lokale Wiedergabeänderungen nach, die Plex nicht erreicht haben. Der günstige Zwei-Wege-Abgleich zwischen den vollen Reconciles. Fällt er aus, erscheinen in der Plex-App gesetzte Gesehen-/Fortsetzungsstände hier später.",
"plex_watch_reconcile": "Ein vollständiger Wiedergabe-Abgleich, der jede Bibliothek neu scannt — erfasst, was der inkrementelle Lauf nicht kann, vor allem eine auf Plex-Seite zurückgenommene Gesehen-Markierung. Schwerer, läuft daher etwa täglich. Fällt er aus, werden in Plex vorgenommene Un-Watches nicht zurückgespiegelt."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)", "rss_poll": "RSS-Abfrage (neue Uploads)",
@ -83,7 +85,9 @@
"demo_reset": "Demo-Konto-Reset", "demo_reset": "Demo-Konto-Reset",
"explore_cleanup": "Bereinigung erkundeter Kanäle", "explore_cleanup": "Bereinigung erkundeter Kanäle",
"download_gc": "Download-Bereinigung", "download_gc": "Download-Bereinigung",
"plex_sync": "Plex-Bibliothek-Sync" "plex_sync": "Plex-Bibliothek-Sync",
"plex_watch_sync": "Plex-Wiedergabe-Sync (inkrementell)",
"plex_watch_reconcile": "Plex-Wiedergabe-Abgleich (vollständig)"
}, },
"queue": { "queue": {
"recentPending": "Zu synchronisierende Kanäle", "recentPending": "Zu synchronisierende Kanäle",

View file

@ -69,7 +69,9 @@
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.", "maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.", "explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.", "download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync." "plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS poll (new uploads)", "rss_poll": "RSS poll (new uploads)",
@ -83,7 +85,9 @@
"demo_reset": "Demo account reset", "demo_reset": "Demo account reset",
"explore_cleanup": "Explored-channel cleanup", "explore_cleanup": "Explored-channel cleanup",
"download_gc": "Download cleanup", "download_gc": "Download cleanup",
"plex_sync": "Plex library sync" "plex_sync": "Plex library sync",
"plex_watch_sync": "Plex watch sync (incremental)",
"plex_watch_reconcile": "Plex watch reconcile (full)"
}, },
"queue": { "queue": {
"recentPending": "Channels to sync", "recentPending": "Channels to sync",

View file

@ -69,7 +69,9 @@
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.", "maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.", "explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.", "download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg." "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.",
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
"plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)", "rss_poll": "RSS-lekérdezés (új feltöltések)",
@ -83,7 +85,9 @@
"demo_reset": "Demo fiók visszaállítása", "demo_reset": "Demo fiók visszaállítása",
"explore_cleanup": "Felfedezett csatornák takarítása", "explore_cleanup": "Felfedezett csatornák takarítása",
"download_gc": "Letöltések takarítása", "download_gc": "Letöltések takarítása",
"plex_sync": "Plex-könyvtár szinkron" "plex_sync": "Plex-könyvtár szinkron",
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)"
}, },
"queue": { "queue": {
"recentPending": "Szinkronra váró csatorna", "recentPending": "Szinkronra váró csatorna",

View file

@ -1244,16 +1244,29 @@ export const api = {
// cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek. // cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek.
plexSubtitleUrl: (id: string, ord: number, offset = 0): string => plexSubtitleUrl: (id: string, ord: number, offset = 0): string =>
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`, `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`,
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> => // `final` marks a settled checkpoint (pause / page-hide / leaving the player) as opposed to the
// periodic 10s tick — the backend mirrors the resume position to a linked Plex account only on a
// final checkpoint, so a single watch doesn't spray Plex with timeline updates.
plexProgress: (
id: string,
position_seconds: number,
duration_seconds: number,
final = false,
): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, { req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
method: "POST", method: "POST",
body: JSON.stringify({ position_seconds, duration_seconds }), body: JSON.stringify({ position_seconds, duration_seconds, final }),
}), }),
// Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is // Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position // cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we // (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
// replicate req()'s credentials + account header (no retry — there's no page left to retry on). // replicate req()'s credentials + account header (no retry — there's no page left to retry on).
plexProgressBeacon: (id: string, position_seconds: number, duration_seconds: number): void => { plexProgressBeacon: (
id: string,
position_seconds: number,
duration_seconds: number,
final = false,
): void => {
const active = getActiveAccount(); const active = getActiveAccount();
try { try {
void fetch(`/api/plex/item/${encodeURIComponent(id)}/progress`, { void fetch(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
@ -1264,7 +1277,7 @@ export const api = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
}, },
body: JSON.stringify({ position_seconds, duration_seconds }), body: JSON.stringify({ position_seconds, duration_seconds, final }),
}).catch(() => {}); }).catch(() => {});
} catch { } catch {
/* ignore */ /* ignore */

View file

@ -14,6 +14,17 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.35.0",
date: "2026-07-10",
summary: "Two-way Plex watch sync: watch in either app and both stay in step.",
features: [
"Two-way Plex watch sync is complete. Mark something watched — or leave it half-finished — in Siftlode and it now updates Plex too; and anything you watch (or un-mark) in the Plex app flows back to Siftlode automatically. Turn it on in Settings → Account. (Your private “hidden” marks stay in Siftlode and are never sent to Plex.)",
],
fixes: [
"Auto-advancing to the next episode now marks the episode you actually finished — not the next one. Previously, if the next episode wasn't available locally, the wrong episode could be marked watched.",
],
},
{ {
version: "0.34.2", version: "0.34.2",
date: "2026-07-09", date: "2026-07-09",