import { useQuery, useQueryClient, type QueryClient, type QueryKey } 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 & { 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>(); /** Track a closing flush until it settles (`progressFlushes` hands out the ones still running). */ export function trackProgressFlush(flush: Promise): void { flushes.add(flush); const settled = () => void flushes.delete(flush); flush.then(settled, settled); } export const progressFlushes = (): Promise[] => [...flushes]; export function patchCachedProgress(qc: QueryClient, positions: Map): void { if (positions.size === 0) return; // Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube // search and every related list — their rows come from the same projection, carry the same // per-user position, and open the same player, so leaving one out left the rewind in place on that // surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota // units, a related list's a scrape and a slot of the day's cap.) patchLists(qc, [qk.feed(), qk.playlist(), qk.ytSearch(), qk.related()], (v) => { const pos = positions.get(v.id); return pos === undefined || v.position_seconds === pos ? v : { ...v, position_seconds: pos }; }); for (const [id, pos] of positions) remember(qc, id, { position_seconds: pos }); } /** Write one video's confirmed change (its status, its Watch-later mark) into the lists that are * never refetched: every related list (both regions', every opened video's) and every live * YouTube search — another scrape and a slot of the day's cap, or 100 quota units, each. A video * marked anywhere else (a feed card, a playlist, the player, the Notification Center's undo) still * showed there unmarked. Called by `features/video/videoWrites`, which every such write goes * through (its contract test holds that). */ export function markVideo(qc: QueryClient, id: string, change: Partial