Files
siftlode/frontend/src/lib/progressCache.ts
T
peter 4869d6c108 fix(player): the resume position reaches the playlist page and live search, not just the feed
Deep-review R4. 1630a3df wrote the YT player's stored position into the react-query cache so a
reopen could not rewind it — but only the feed actually read it back:

- Playlists renders from its own undoable copy of the list, re-seeded only when the SET of ids
  changes, so the patched position never reached it: close a video at 830 s, reopen it from the
  playlist, and it resumed at the old spot and the next checkpoint wrote that over the 830. A detail
  with the same membership now rebases its fresh rows into every snapshot, keeping order and undo
  history. (The ids-only guard is older than this round; the write that depended on it was new.)
- Live YouTube search results carry the same per-user position and open the same player, but their
  cache was never patched. It is now — patched only, never invalidated, since refetching an
  API-sourced search page spends 100 quota units.

Tests for what had none: a Playlists harness (PlayerModal stubbed to record its launch props) and a
PlayerModal harness with a fake IFrame API that records where each item is started — the latter pins
that stepping back to an item watched earlier in the same session resumes at THIS session's position.
3 mutations, each red on its assertion.
2026-09-11 01:19:14 +02:00

72 lines
3.3 KiB
TypeScript

import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import type { Video } from "./api";
// Every cached list that carries feed videos is one of two shapes: a single object with `items`
// (a playlist detail) or an infinite query's `{ pages: [...] }` over those objects. Narrow
// structurally rather than by type name so a new list surface is covered the day it is added.
type ItemList = { items: Video[] };
type Cached = Partial<ItemList> & { pages?: unknown[] };
const isItemList = (v: unknown): v is ItemList =>
!!v && typeof v === "object" && Array.isArray((v as ItemList).items);
/**
* Write server-stored resume positions into every cached list holding those videos.
*
* The player checkpoints progress every few seconds, but until this ran, nothing wrote the new
* position back into react-query — so the feed cache kept the value it was fetched with, a reopen
* of the same card resumed from THAT stale value, and the next checkpoint then persisted the older
* position over the newer one (measured: 830s → reopen → 751s). The Plex player already solved the
* same problem for its detail query (`plexPlayerHooks`, "reopening the same item in the SAME
* session read react-query's CACHED detail"); this is the feed-side twin.
*
* `positions` must hold what the SERVER stored (`api.saveProgress`'s answer), not what the player
* read: the endpoint clears trivially-early and near-finished checkpoints to 0, and mirroring our
* own reading would re-introduce that disagreement on the client.
*/
export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void {
if (positions.size === 0) return;
const patchList = (items: Video[]): Video[] => {
let changed = false;
const next = items.map((v) => {
const pos = positions.get(v.id);
if (pos === undefined || v.position_seconds === pos) return v;
changed = true;
return { ...v, position_seconds: pos };
});
// Keep the previous array identity when nothing moved, so an untouched list does not
// re-render every card behind the open player.
return changed ? next : items;
};
const patch = (old: Cached | undefined): Cached | undefined => {
if (!old) return old;
if (Array.isArray(old.pages)) {
let changed = false;
const pages = old.pages.map((page) => {
if (!isItemList(page)) return page;
const items = patchList(page.items);
if (items === page.items) return page;
changed = true;
return { ...page, items };
});
return changed ? { ...old, pages } : old;
}
if (isItemList(old)) {
const items = patchList(old.items);
return items === old.items ? old : { ...old, items };
}
return old;
};
// Prefix keys: every filter combination of the feed, every playlist detail, and every live YouTube
// search — its rows come from the same projection, carry the same per-user position, and open the
// same player, so leaving it out left the rewind in place on that surface. (Patched, never
// invalidated: a refetch of an API-sourced search page spends 100 quota units.)
qc.setQueriesData<Cached>({ queryKey: qk.feed() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
}