diff --git a/frontend/src/components/LibraryPlayer.dom.test.tsx b/frontend/src/components/LibraryPlayer.dom.test.tsx index 0e3ec03d..1cae2383 100644 --- a/frontend/src/components/LibraryPlayer.dom.test.tsx +++ b/frontend/src/components/LibraryPlayer.dom.test.tsx @@ -198,6 +198,36 @@ describe("LibraryPlayer", () => { expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]); // Closing the player sends the point the way that survives the page going away. unmount(); - expect(beacon).toHaveBeenCalledWith(7, 125.4, 600); + expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function)); + }); + + it("keeps the closing point over a list fetch that read the server before the point landed", async () => { + // The listing polls. One already out when the player closed answered with the point the server + // held BEFORE the closing beacon, over the row's patch — and Continue then opened there, and its + // first checkpoint wrote that older point back over the right one. + let settled: (() => void) | undefined; + vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation((_id, _at, _dur, onSettled) => { + settled = onSettled; + }); + const client = new QueryClient(); + const job = withAsset({ width: 1280, height: 720 }); + client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]); + let answer: (jobs: DownloadJob[]) => void = () => {}; + const poll = client.fetchQuery({ + queryKey: qk.downloads(), + queryFn: () => new Promise((r) => (answer = r)), + }); + const { unmount } = render( {}} />, client); + const video = loaded(); + video.dispatchEvent(new Event("playing")); + video.currentTime = 300; + unmount(); + answer([{ ...job, position_seconds: 10 }]); + await poll; + expect(client.getQueryData(qk.downloads())![0]!.position_seconds).toBe(300); + // Once the point has landed, the listing asks the server again. + const invalidate = vi.spyOn(client, "invalidateQueries"); + settled?.(); + expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true }); }); }); diff --git a/frontend/src/components/LibraryPlayer.tsx b/frontend/src/components/LibraryPlayer.tsx index e2667d35..84b2ebfa 100644 --- a/frontend/src/components/LibraryPlayer.tsx +++ b/frontend/src/components/LibraryPlayer.tsx @@ -69,16 +69,24 @@ export default function LibraryPlayer({ }); useMediaKeys(media); // The library row's bar follows the point at once — both listings this job may sit in — rather - // than after the next refetch, which may land before the closing beacon does. + // than after the next refetch. On the way out a listing poll may already be out, having read the + // server before the closing point lands: it is cancelled, or its answer replaced the row's point + // with the older one, Continue opened there, and its first checkpoint wrote it back over the right + // one. Once the point is on the server, the listings are read again. + const lists = [qk.downloads(), qk.downloadsShared()]; const keepPoint = (position: number, duration: number, leaving: boolean) => { const at = keptPosition(position, duration); - for (const key of [qk.downloads(), qk.downloadsShared()]) { + for (const key of lists) { + if (leaving) void qc.cancelQueries({ queryKey: key, exact: true }); qc.setQueryData(key, (jobs) => jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)), ); } - if (leaving) api.saveDownloadProgressBeacon(job.id, position, duration); - else void api.saveDownloadProgress(job.id, position, duration).catch(() => {}); + if (leaving) { + api.saveDownloadProgressBeacon(job.id, position, duration, () => { + for (const key of lists) void qc.invalidateQueries({ queryKey: key, exact: true }); + }); + } else void api.saveDownloadProgress(job.id, position, duration).catch(() => {}); }; useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint); diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 2e5011bb..1f7dbb9e 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -288,8 +288,9 @@ export function identityHeaders(): Record { // Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position // beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup // doesn't run on a full reload, so without keepalive the last position (esp. right after a seek) -// would be lost. -export function beacon(url: string, body: Record): void { +// would be lost. `onSettled`: the request has been answered (or failed) — for a caller whose cached +// view must be read again once the write is on the server. +export function beacon(url: string, body: Record, onSettled?: () => void): void { try { void send(url, { method: "POST", @@ -297,7 +298,10 @@ export function beacon(url: string, body: Record): void { credentials: "include", headers: { "Content-Type": "application/json", ...identityHeaders() }, body: JSON.stringify(body), - }).catch(() => {}); + }).then( + () => onSettled?.(), + () => onSettled?.(), + ); } catch { /* ignore */ } diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index d8c0f7c5..aad414c6 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -299,11 +299,16 @@ export const downloadsApi = { id: number, positionSeconds: number, durationSeconds: number, + onSettled?: () => void, ): void => - beacon(`/api/downloads/${id}/progress`, { - position_seconds: Math.floor(positionSeconds), - duration_seconds: Math.floor(durationSeconds), - }), + beacon( + `/api/downloads/${id}/progress`, + { + position_seconds: Math.floor(positionSeconds), + duration_seconds: Math.floor(durationSeconds), + }, + onSettled, + ), // A plain navigation can't send the X-Siftlode-Account header, so pass the active // account via ?account= (the server honours it only for an account in this browser's wallet) — // otherwise the download resolves to the session-default account and 404s for a per-tab