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
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
//
// The YT player's progress checkpoint must reach the CACHE, not just the server. Before this, the
// modal's cleanup only marked `qk.feed()` stale with `refetchType: "none"` — and nothing refetches
// the feed until it remounts (staleTime 30s, refetchOnWindowFocus off), so reopening the same card
// in the same session resumed from the position the cache had been fetched with, and the next
// checkpoint then persisted THAT older value over the newer one. Measured on localdev before the
// fix: played to 830s → closed (server had 830) → reopened → the row was back to 751s.
//
// Its own file on purpose: `loadYouTubeApi` memoises the API promise in a module-level singleton,
// so a test that installs `window.YT` cannot share a module instance with tests that do not.
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
import { createRef, type MutableRefObject } from "react";
import { useYtProgress } from "./playerModalHooks";
import { patchCachedProgress } from "./progressCache";
import { qk } from "./queryKeys";
import { api, type Video } from "./api";
import type { YTPlayer } from "./youtube";
const VID = "EC5oFYy9z9g";
const video = (over: Partial<Video> = {}): Video =>
({ id: VID, title: "Melyik szállás a Drágább?", position_seconds: 748, ...over }) as Video;
/** A feed cache shaped like the real infinite query: pages of `{ items }`. */
const seedFeed = (qc: QueryClient, v: Video) =>
qc.setQueryData(qk.feed({ q: "" } as never), { pages: [{ items: [v] }], pageParams: [null] });
const cachedPos = (qc: QueryClient): number | undefined => {
const data = qc.getQueryData(qk.feed({ q: "" } as never)) as
| { pages: { items: Video[] }[] }
| undefined;
return data?.pages[0]?.items[0]?.position_seconds;
};
// ONE fake namespace with a MUTABLE clock, installed once. `loadYouTubeApi` memoises the API
// promise in a module-level singleton, so the namespace the first mount resolves is the one every
// later mount gets — re-installing a fresh `window.YT` per test would silently have no effect (an
// earlier draft of this file did exactly that, and its "near the end" case was really still
// reading the first test's clock).
const clock = { current: 0, duration: 0 };
const fakePlayer = {
getCurrentTime: () => clock.current,
getDuration: () => clock.duration,
destroy: () => {},
} as unknown as YTPlayer;
window.YT = {
Player: function () {
return fakePlayer;
} as never,
};
/** Point the shared fake player at a position. */
function atPosition(currentTime: number, duration: number) {
clock.current = currentTime;
clock.duration = duration;
}
/** Let the API promise, the player construction and the checkpoint POST all settle. */
const settle = async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 6; i++) await Promise.resolve();
};
function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
const mount = document.createElement("div");
document.body.appendChild(mount);
return renderHook(() =>
useYtProgress({
activeId: VID,
resumeAt: 748,
mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as MutableRefObject<YTPlayer | null>,
currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
setWatched: () => {},
setPlayerError: () => {},
setLiveData: () => {},
reapplyStored: () => {},
focusModal: () => {},
advanceOnEnd: () => {},
sessionPosRef: { current: sessionPos },
qc,
}),
);
}
describe("useYtProgress — the checkpoint reaches the cache", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("writes the position the player reached into the cached feed on close", async () => {
const qc = new QueryClient();
seedFeed(qc, video());
atPosition(830, 3118);
const save = vi
.spyOn(api, "saveProgress")
.mockResolvedValue({ video_id: VID, position_seconds: 830 });
const { unmount } = mountPlayer(qc, new Map());
await settle();
unmount();
await settle();
expect(save).toHaveBeenCalled();
// Without the patch this stays 748 — the value a reopen would resume from, and then persist
// back over the 830 the server already holds.
expect(cachedPos(qc)).toBe(830);
});
it("mirrors what the SERVER stored, not what the player read", async () => {
const qc = new QueryClient();
seedFeed(qc, video({ position_seconds: 748 }));
// 3s in — under the endpoint's 5s floor, so it stores 0 and answers 0. (The OTHER clearing
// rule, near-the-end, is unreachable from here on purpose: the player auto-marks watched and
// deliberately skips that checkpoint, so it never asks the server at all.)
atPosition(3, 3118);
vi.spyOn(api, "saveProgress").mockResolvedValue({ video_id: VID, position_seconds: 0 });
const sessionPos = new Map<string, number>();
const { unmount } = mountPlayer(qc, sessionPos);
await settle();
unmount();
await settle();
// Mirroring `getCurrentTime()` would put 3 here — a resume bar and a resume point the server
// does not have, which the next checkpoint would then keep re-asserting.
expect(sessionPos.get(VID)).toBe(0);
expect(cachedPos(qc)).toBe(0);
});
});
describe("patchCachedProgress", () => {
it("patches a playlist detail and leaves untouched lists identical", () => {
const qc = new QueryClient();
const other = video({ id: "other", position_seconds: 12 });
qc.setQueryData(qk.playlist(3), { id: 3, items: [video(), other] });
const before = qc.getQueryData(qk.playlist(3)) as { items: Video[] };
patchCachedProgress(qc, new Map([[VID, 830]]));
const after = qc.getQueryData(qk.playlist(3)) as { items: Video[] };
expect(after.items[0]?.position_seconds).toBe(830);
// Same object identity for the row that did not change — so an open player's checkpoint does
// not re-render every card in the list behind it.
expect(after.items[1]).toBe(before.items[1]);
expect(after.items[1]?.position_seconds).toBe(12);
});
it("is a no-op when nothing was stored", () => {
const qc = new QueryClient();
seedFeed(qc, video());
const before = qc.getQueryData(qk.feed({ q: "" } as never));
patchCachedProgress(qc, new Map());
expect(qc.getQueryData(qk.feed({ q: "" } as never))).toBe(before);
});
});