fix(plex): a stale finish refusal no longer sticks, and a refusal keeps the player's detail
A refusal that landed after a replay raised the refusal flag for the finish that watch made later, so leaving skipped the watched patch. Each finish now resets the flag and a refusal acts only for the finish it answers. A refusal while the player still sat on the item (the up-next card) removed the detail query it observes, dropping the card and restarting its countdown, or losing it if the refetch failed. It is now refetched under the player and dropped only when the player leaves.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The finish latch's refusal path, against a real query cache.
|
||||
//
|
||||
// A refusal answers ONE finish: a replay begun while it was in flight starts a new watch, and a
|
||||
// refusal that lands after it must not take back the finish that watch later makes — that left the
|
||||
// cache saying unwatched, mid-episode, for an item the server had watched.
|
||||
//
|
||||
// And a refusal while the player is still on the item (the up-next card, the default end state) must
|
||||
// not empty the detail that player observes: the card is gated on it, so it dropped, came back with its
|
||||
// countdown restarted when the refetch landed, and never came back if that refetch failed too. The
|
||||
// entry is dropped when the player leaves, where the next open has to fetch the truth.
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||
import { type ReactNode } from "react";
|
||||
import { usePlexProgress } from "./plexPlayerHooks";
|
||||
import { api, type PlexItemDetail } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import i18n, { plexLang } from "../i18n";
|
||||
|
||||
const RK = "4242";
|
||||
const key = () => qk.plexItem(RK, plexLang(i18n.language));
|
||||
const detail = { id: RK, status: "new", position_seconds: 600 } as unknown as PlexItemDetail;
|
||||
|
||||
function deferred() {
|
||||
let reject: (e: unknown) => void = () => {};
|
||||
const promise = new Promise((_, rej) => {
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, reject };
|
||||
}
|
||||
|
||||
const flush = () =>
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
describe("usePlexProgress — a refused finish", () => {
|
||||
let qc: QueryClient;
|
||||
let video: HTMLVideoElement;
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
video = document.createElement("video");
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue({ ok: true } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const setup = (observe: boolean) => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
// Stable, as the player's own refs are: the effect re-runs on a new ref object, and a re-run
|
||||
// starts the item over.
|
||||
const refs = {
|
||||
videoRef: { current: video },
|
||||
absRef: { current: 1500 },
|
||||
durationRef: { current: 3000 },
|
||||
};
|
||||
return renderHook(
|
||||
() => {
|
||||
useQuery({
|
||||
queryKey: key(),
|
||||
queryFn: () => Promise.resolve(detail),
|
||||
enabled: observe,
|
||||
});
|
||||
return usePlexProgress({
|
||||
id: RK,
|
||||
qc,
|
||||
...refs,
|
||||
});
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
["before", true],
|
||||
["after", false],
|
||||
])(
|
||||
"a refusal landing after a replay, %s its next finish, takes back neither",
|
||||
async (_, early) => {
|
||||
qc.setQueryData(key(), detail);
|
||||
const { result } = setup(false);
|
||||
const first = deferred();
|
||||
act(() => result.current.finish(first.promise));
|
||||
act(() => void video.dispatchEvent(new Event("play"))); // the viewer plays it again
|
||||
if (early) {
|
||||
first.reject(new Error("refused")); // only now is the first finish refused
|
||||
await flush();
|
||||
}
|
||||
act(() => result.current.finish(Promise.resolve()));
|
||||
await flush();
|
||||
if (!early) {
|
||||
first.reject(new Error("refused"));
|
||||
await flush();
|
||||
}
|
||||
act(() => result.current.settle());
|
||||
expect(qc.getQueryData(key())).toMatchObject({ status: "watched", position_seconds: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the detail the still-open player observes, and drops it when the player leaves", async () => {
|
||||
const { result, unmount } = setup(true);
|
||||
await flush();
|
||||
expect(qc.getQueryData(key())).toBeDefined();
|
||||
const refused = deferred();
|
||||
act(() => result.current.finish(refused.promise));
|
||||
refused.reject(new Error("refused"));
|
||||
await flush();
|
||||
expect(qc.getQueryData(key())).toBeDefined();
|
||||
unmount();
|
||||
expect(qc.getQueryData(key())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -214,6 +214,14 @@ export function usePlexProgress(opts: {
|
||||
// write; this closes the other three. What the refusal does take back is the
|
||||
// CLAIM: the cache must not say watched, and `settle` must not re-assert it on the way out.
|
||||
let finishRefused = false;
|
||||
// Whether the player has left the item (`settle` ran): what a refusal does to the cached detail
|
||||
// depends on whether the player is still observing it.
|
||||
let left = false;
|
||||
// Which finish a refusal answers. A replay starts a new watch, so a refusal of an earlier finish
|
||||
// that lands after it must not raise `finishRefused` for the finish that watch makes later — the
|
||||
// leave then skipped its watched patch, and the cache said unwatched, mid-item, for an item the
|
||||
// server had watched.
|
||||
let finishGen = 0;
|
||||
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
|
||||
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
|
||||
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
|
||||
@@ -261,6 +269,7 @@ export function usePlexProgress(opts: {
|
||||
const onPlayAgain = () => {
|
||||
finished = false;
|
||||
finishRefused = false;
|
||||
finishGen = finishGen + 1;
|
||||
};
|
||||
video?.addEventListener("play", onPlayAgain);
|
||||
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
|
||||
@@ -273,12 +282,14 @@ export function usePlexProgress(opts: {
|
||||
const settle = () => {
|
||||
// Match the language-keyed detail query so the optimistic resume patch lands.
|
||||
const key = qk.plexItem(id, plexLang(i18n.language));
|
||||
left = true;
|
||||
if (finished) {
|
||||
// A refused finish claims nothing: the server has the item unwatched at the position the
|
||||
// rollback restored, and the catch below has already dropped this entry, so the next open
|
||||
// fetches the truth. Re-patching "watched / 0" here would put back the very lie the
|
||||
// refusal handler removed.
|
||||
if (!finishRefused)
|
||||
// rollback restored. Re-patching "watched / 0" here would put back the very lie the refusal
|
||||
// handler took back, and the entry the player kept observing (see the catch below) may
|
||||
// still be the one from before the rollback — dropped, the next open fetches the truth.
|
||||
if (finishRefused) qc.removeQueries({ queryKey: key });
|
||||
else
|
||||
qc.setQueryData(key, (old) =>
|
||||
old ? { ...old, status: "watched", position_seconds: 0 } : old,
|
||||
);
|
||||
@@ -293,6 +304,9 @@ export function usePlexProgress(opts: {
|
||||
settleRef.current = settle;
|
||||
finishRef.current = (confirmed?: Promise<unknown>) => {
|
||||
finished = true;
|
||||
finishRefused = false;
|
||||
finishGen = finishGen + 1;
|
||||
const gen = finishGen;
|
||||
// The latch has to go up BEFORE the media pauses, so it cannot wait for the server. If the
|
||||
// watched write is then refused, what is taken back is the CLAIM, not the latch (see
|
||||
// `finishRefused`): the cached detail said watched / position 0 while the server still had
|
||||
@@ -306,6 +320,7 @@ export function usePlexProgress(opts: {
|
||||
// position left an unwatched item with a ~99% bar that reopened in its last seconds.
|
||||
const ended = video?.ended === true;
|
||||
void confirmed?.catch(() => {
|
||||
if (gen !== finishGen) return;
|
||||
finishRefused = true;
|
||||
// Restore the resume point the finish replaced — but ONLY where there is one to restore.
|
||||
// On the play-to-the-end path `pos` IS the end of the media, and the backend reads a
|
||||
@@ -316,10 +331,15 @@ export function usePlexProgress(opts: {
|
||||
// credits-skip caller captures a mid-episode position and is unaffected.
|
||||
if (!ended && pos > 0 && dur > 0 && pos <= dur - FINISH_MARGIN_S)
|
||||
api.plexProgress(id, pos, dur, true).catch(() => {});
|
||||
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
|
||||
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
|
||||
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
|
||||
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) });
|
||||
// Still on the item (the up-next card is the default end state), the player observes this
|
||||
// entry: emptying it dropped the card — gated on the detail — and its countdown restarted
|
||||
// when the refetch landed, or the card never came back if that failed too. So it is
|
||||
// refetched under the player, and dropped when the player leaves (`settle`). Already left,
|
||||
// `settle` may have patched it to watched / 0 on the way out, and an invalidated query with
|
||||
// no observer keeps serving that lie: dropped now, the next open fetches the truth.
|
||||
const key = qk.plexItem(id, plexLang(i18n.language));
|
||||
if (left) qc.removeQueries({ queryKey: key });
|
||||
else void qc.invalidateQueries({ queryKey: key });
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user