Files
siftlode/frontend/src/lib/progressCache.ts
T
peter 2cff4ad954 fix(feed): an override yields only to a refetch that set out after its write was answered
Send order let a refetch read before an unanswered mark committed and drop it. A reset's position 0 is written again after a refetch that set out before the server took the reset. The player-session refetch also waits for the player's closing checkpoint, which the player now tracks (progressFlushes); refetched first, the list put the older position back.
2026-10-09 23:46:39 +02:00

85 lines
3.9 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.
*/
// The player's closing flushes still on their way: its last checkpoint and the patch above. A list
// refetched before one lands read the older checkpoint and, landing after the patch, put it back.
const flushes = new Set<Promise<unknown>>();
/** Track a closing flush until it settles (`progressFlushes` hands out the ones still running). */
export function trackProgressFlush(flush: Promise<unknown>): void {
flushes.add(flush);
const settled = () => void flushes.delete(flush);
flush.then(settled, settled);
}
export const progressFlushes = (): Promise<unknown>[] => [...flushes];
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);
}