fix(plex): the reclaim budget bounds what it says it bounds, and a refused finish is not performed anyway
`MAX_SESSION_RECLAIMS` was cleared by the first fragment of any session, and a reclaimed session essentially always delivers one — `start_session` does not return until its first segment exists. So the budget only ever bit where a session parsed its manifest and then died before a single fragment, which is not either case its own comment names. Measured: 40 loss cycles produced 41 sessions and never an error. A transcode falling behind realtime, or two viewers of one rating_key taking turns, respawned ffmpeg indefinitely — the round-5 behaviour, still there behind the guard added to stop it. It counts within a rolling window now. Two losses inside two minutes is a session that keeps being lost; one an hour apart is not. And the window is reset per item, because the player stays mounted across a queue advance — otherwise a budget spent on one episode would deny the next its first legitimate reclaim. Separately: `finish`'s rollback re-performed the write it was taking back. On the play-to-the-end path the position it restores IS the end of the media, and the backend reads a position within its finish margin as a finish — so the branch named "take the finish back" set watched, cleared the position and scrobbled to Plex through a second route, while the caller's error toast was already on screen saying the mark had failed. There is no resume point at the end of the media to restore; the cache drop is the whole of the rollback there. The credits-skip caller captures a mid-episode position and still restores it, which is the case that needs it. 7 tests, mutation-checked: the FRAG_LOADED reset, the missing window, and the unguarded rollback each red on the test that names them.
This commit is contained in:
@@ -371,14 +371,39 @@ describe("leaving an item that was played through", () => {
|
||||
endMedia();
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
// The position the viewer actually reached is reported, not suppressed by a finish that the
|
||||
// server never confirmed…
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
|
||||
["A", 2995, 3000, true],
|
||||
]);
|
||||
// …and the cached detail is not left claiming watched: only the server is honest now.
|
||||
// The cached detail is not left claiming watched: only the server is honest now.
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect(cached?.status).not.toBe("watched");
|
||||
// And the rollback does NOT write the position back. 2995 of 3000 is inside the backend's
|
||||
// finish margin, so that checkpoint would have set watched / position 0 and scrobbled to Plex
|
||||
// — performing, through a second route, the very write that was just refused, while the error
|
||||
// toast said it had failed (round 6). There is no resume point at the end of the media to
|
||||
// restore; the cache drop is the whole of the rollback here.
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does restore the position when the refused finish came from the credits skip", async () => {
|
||||
// The other caller of the same helper captures a MID-episode position, and there the rollback
|
||||
// is the point: without it the viewer loses the stretch they watched. One helper, two callers,
|
||||
// and only the near-end one must hold back.
|
||||
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2000, end_s: 2990 }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc, progress } = mount({
|
||||
details: { A: withCredits, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await playingAt("/media/A", 2010); // inside the credits → auto-skip advances and marks watched
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
|
||||
["A", 2010, 3000, true],
|
||||
]);
|
||||
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
|
||||
});
|
||||
|
||||
it("records a second viewing when the viewer plays on from the finished frame", async () => {
|
||||
|
||||
Reference in New Issue
Block a user