fix(library): a listing poll can no longer put back an older resume point
A poll already out when the player closed answered with the point the server held before the closing beacon, over the row's patch; Continue then opened there and wrote it back. The close now cancels the listings' fetches before patching and reads them again once the beacon has landed.
This commit is contained in:
@@ -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<DownloadJob[]>((r) => (answer = r)),
|
||||
});
|
||||
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
||||
const video = loaded();
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 300;
|
||||
unmount();
|
||||
answer([{ ...job, position_seconds: 10 }]);
|
||||
await poll;
|
||||
expect(client.getQueryData<DownloadJob[]>(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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DownloadJob[]>(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);
|
||||
|
||||
|
||||
@@ -288,8 +288,9 @@ export function identityHeaders(): Record<string, string> {
|
||||
// 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<string, unknown>): 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<string, unknown>, onSettled?: () => void): void {
|
||||
try {
|
||||
void send(url, {
|
||||
method: "POST",
|
||||
@@ -297,7 +298,10 @@ export function beacon(url: string, body: Record<string, unknown>): void {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...identityHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {});
|
||||
}).then(
|
||||
() => onSettled?.(),
|
||||
() => onSettled?.(),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -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 <a> 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
|
||||
|
||||
Reference in New Issue
Block a user