fix(player): the YT progress checkpoint reaches the cache, so a reopen cannot rewind it

The player checkpointed every 5s and the server stored it, but nothing wrote the new
position back into react-query: the close path only marked qk.feed() stale with
refetchType "none", and nothing refetches the feed until it remounts (staleTime 30s,
refetchOnWindowFocus off). So the resume read the position the cache had been FETCHED
with, and the next checkpoint then persisted that older value over the newer one.

Measured on localdev: played to 830s, closed (server had 830), reopened the same card
without a reload -> the row was back to 751s. 79 seconds of progress destroyed by a
reopen, and the card's progress bar never moved either.

The Plex player already solved this for its detail query, with a comment describing the
identical failure; this is the feed-side twin that was missed in that sweep. patchCachedProgress
writes the position the SERVER stored (the endpoint clears trivially-early and near-finished
checkpoints to 0, so mirroring our own reading would disagree with the row) into every cached
feed page and playlist detail, keeping array identity where nothing changed. A session map
covers the second half: the launch queue is frozen at mount, so stepping next->prev back to an
item played earlier in the same session resumed from that snapshot.

Verified in the browser: 811 -> close 816 -> reopen 825, and the card now shows Continue/Restart.
This commit is contained in:
2026-09-09 23:55:51 +02:00
parent 14b5fe2e31
commit 1630a3df3c
5 changed files with 277 additions and 5 deletions
+16 -2
View File
@@ -40,6 +40,12 @@ export interface VideoDetail {
duration_seconds: number | null;
}
/** The stored answer of `POST /api/videos/{id}/progress` — the row as it now stands. */
export interface StoredProgress {
video_id: string;
position_seconds: number;
}
export interface FeedResponse {
items: Video[];
has_more: boolean;
@@ -179,8 +185,16 @@ export const feedApi = {
),
clearState: (id: string) =>
req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
req(
// Returns the position the server actually STORED — which is not always what we sent: a
// trivially-early or near-finished checkpoint is cleared to 0 (see set_video_progress). Callers
// that mirror the position into a cache must write this answer, never their own reading, so the
// client can never disagree with the row.
saveProgress: (
id: string,
positionSeconds: number,
durationSeconds: number,
): Promise<StoredProgress> =>
req<StoredProgress>(
`/api/videos/${id}/progress`,
{
method: "POST",