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:
2026-09-12 05:50:11 +02:00
parent f8a31d48cf
commit d89d887a9b
4 changed files with 102 additions and 25 deletions
@@ -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 () => {