Files
silo-server/web/src/hooks/queries/playbackProgressCache.ts
T
46540dfec3 fix(progress): track resume points independently of watched state (#117)
* fix(progress): track resume points independently of watched state

Re-watching a finished item never re-entered Continue Watching: completion
latched completed = TRUE one-way, pinned position_seconds to the duration,
and the resume query filtered on completed = FALSE — so a rewatch heartbeat
could never surface the item again (and releasing the latch would have
erased the watched state clients display).

Adopt the Jellyfin invariant instead of guard heuristics:

- Completion resets position_seconds to 0 (UpdateProgress, SetProgress,
  SetProgressAt, SetProgressIfNewer, MarkWatched, MarkProgressBatch), so
  position_seconds > 0 now means "live resume point".
- completed stays a pure one-way watched latch; rewatch heartbeats re-enter
  Continue Watching through plain GREATEST/MAX while the watched flag and
  PlayCount survive (matching Plex and Jellyfin master).
- ListProgress("in_progress") keys on position_seconds > 0 in both stores;
  the SQLite store also gains the min-resume floor the Postgres store had.
- jellycompat reports Played=true with live PositionTicks during a rewatch
  (resumePositionTicks no longer zeroes played items) — the DTO shape real
  Jellyfin emits since jellyfin/jellyfin#15762.
- Web mirrors the latch (playbackProgressCache), resumes rewatches at their
  stored position, and shows progress bars on rewatched episodes.
- ABS audiobook surfaces keep today's behavior: finished books report 100%
  via the completed flag and Continue Listening still excludes them.
- Migrations reset legacy completed rows (position pinned to duration) to
  0: a Goose migration for Postgres and a user_version-gated one-time fix
  for the per-user SQLite DBs.

Replaces the guard-based approach of #109, whose restart detection
(50% fraction + 60s time gap) could never release the latch for immediate
rewatches (blocked heartbeats refreshed updated_at, re-arming the gap) and
un-watched items on position-0 heartbeats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(progress): address review — migration gate, one-way latch, missed writers/readers

Review fixes for the position-based watch-progress model:

- The per-user SQLite data fix is now migrateToV11 in the existing
  versioned runMigrations chain (schemaVersion 11). The previous
  standalone PRAGMA gate compared against 1, but existing DBs already
  sit at user_version 10, so the reset never ran for them — and the
  gate would have rewound the version. Fresh DBs short-circuit to the
  current version as before.
- `completed` is now one-way across every playback/sync writer:
  SetProgress (the RecordPlaybackStop path — stopping a rewatch below
  the watched threshold no longer clears the watched state),
  SetProgressAt, SetProgressIfNewer (both stores), and the history
  import upsert, which also stops pinning completed imports to
  position = duration. Mark-unwatched still releases the latch via
  ClearProgress/ClearProgressBatch.
- MarkProgressBatch regains its freshness guard: a delayed batch mark
  carrying an old timestamp can no longer zero a newer rewatch resume
  point (the position-reset now rides the original updated_at check).
- Catalog read paths align with the new in-progress definition
  (position_seconds > 0, completed-agnostic): smart-collection
  in_progress filter, progress sort ratio, episode progress CTE, and
  both next-up predicates.
- jellycompat derives PlayedPercentage and PlaybackPositionTicks from
  the same clamped position; a played item at rest reports 100 (as the
  old model did) while a rewatch reports its live fraction.
- ABS audiobook UpsertProgress stores position 0 on finish so finished
  books can't surface as phantom resume entries; re-listens still move
  position forward from 0 with the latch intact.
- The web optimistic cache zeroes the resume point on completion,
  mirroring the server invariant until the refetch lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:44:40 -04:00

118 lines
4.1 KiB
TypeScript

import type { QueryClient } from "@tanstack/react-query";
import type { ItemDetail, LeafItemUserData, ProgressListResponse, WatchDetail } from "@/api/types";
import { progressKeys } from "./keys";
export interface PlaybackProgressSnapshot {
contentId: string;
positionSeconds: number;
durationSeconds?: number;
lastFileId?: number | null;
lastResolution?: string;
lastHDR?: boolean;
lastCodecVideo?: string;
lastEditionKey?: string;
updatedAt?: string;
}
function mergeLeafProgress(
existing: LeafItemUserData | undefined,
snapshot: PlaybackProgressSnapshot,
): LeafItemUserData {
const rawPosition = Math.max(0, snapshot.positionSeconds);
const durationSeconds = snapshot.durationSeconds ?? existing?.duration_seconds;
// Mirror the server model: played is a one-way latch (a rewatch keeps the
// watched badge), completion clears the resume point, and any nonzero
// position is an active resume point.
const completedNow =
durationSeconds != null && durationSeconds > 0 && rawPosition >= durationSeconds;
const played = existing?.played === true || completedNow;
const positionSeconds = completedNow ? 0 : rawPosition;
return {
played,
is_in_progress: positionSeconds > 0,
position_seconds: positionSeconds,
duration_seconds: durationSeconds,
last_file_id: snapshot.lastFileId ?? existing?.last_file_id,
last_resolution: snapshot.lastResolution ?? existing?.last_resolution,
last_hdr: snapshot.lastHDR ?? existing?.last_hdr,
last_codec_video: snapshot.lastCodecVideo ?? existing?.last_codec_video,
last_edition_key: snapshot.lastEditionKey ?? existing?.last_edition_key,
};
}
function applySnapshotToItemDetail(
existing: ItemDetail | undefined,
snapshot: PlaybackProgressSnapshot,
): ItemDetail | undefined {
if (!existing) return existing;
const currentUserData =
existing.user_data && "position_seconds" in existing.user_data ? existing.user_data : undefined;
return {
...existing,
user_data: mergeLeafProgress(currentUserData, snapshot),
};
}
function applySnapshotToWatchDetail(
existing: WatchDetail | undefined,
snapshot: PlaybackProgressSnapshot,
): WatchDetail | undefined {
if (!existing) return existing;
return {
...existing,
user_data: mergeLeafProgress(existing.user_data, snapshot),
};
}
export function applyPlaybackProgressToCache(
queryClient: QueryClient,
snapshot: PlaybackProgressSnapshot,
) {
for (const [queryKey, existing] of queryClient.getQueriesData<ItemDetail>({
queryKey: ["catalog", "items", snapshot.contentId, "detail"],
})) {
queryClient.setQueryData<ItemDetail>(queryKey, applySnapshotToItemDetail(existing, snapshot));
}
for (const [queryKey, existing] of queryClient.getQueriesData<ItemDetail>({
queryKey: ["items", "detail", snapshot.contentId],
})) {
queryClient.setQueryData<ItemDetail>(queryKey, applySnapshotToItemDetail(existing, snapshot));
}
for (const [queryKey, existing] of queryClient.getQueriesData<WatchDetail>({
queryKey: ["items", "watchDetail", snapshot.contentId],
})) {
queryClient.setQueryData<WatchDetail>(queryKey, applySnapshotToWatchDetail(existing, snapshot));
}
const updatedAt = snapshot.updatedAt ?? new Date().toISOString();
for (const [queryKey, existing] of queryClient.getQueriesData<ProgressListResponse>({
queryKey: progressKeys.all,
})) {
if (!existing) continue;
const nextProgress = existing.progress.map((entry) => {
if (entry.media_item_id !== snapshot.contentId) return entry;
const rawPosition = Math.max(0, snapshot.positionSeconds);
const duration = snapshot.durationSeconds ?? entry.duration_seconds;
// Mirror the server model: completion latches and clears the resume point.
const completedNow = duration > 0 && rawPosition >= duration;
return {
...entry,
position_seconds: completedNow ? 0 : rawPosition,
duration_seconds: duration,
completed: entry.completed || completedNow,
updated_at: updatedAt,
};
});
queryClient.setQueryData<ProgressListResponse>(queryKey, {
...existing,
progress: nextProgress,
});
}
}