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:
@@ -174,16 +174,19 @@ describe("an HLS session the server no longer has", () => {
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("starts the budget over once a fragment actually loads", async () => {
|
||||
// A session lost once an hour is not a session that cannot be served; the only proof a reclaim
|
||||
// worked is a fragment arriving.
|
||||
it("keeps giving up even though every reclaimed session plays a fragment first", async () => {
|
||||
// The budget used to be cleared by the first FRAG_LOADED of any session — and a reclaimed
|
||||
// session essentially always delivers one, because `start_session` does not return until its
|
||||
// first segment exists. So it bounded NEITHER case its own comment named: measured at 40 loss
|
||||
// cycles, the player opened 41 sessions and never showed an error (deep-review round 6). A
|
||||
// fragment is not proof the session can be served; surviving the window is.
|
||||
const session = mount();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
const v = document.querySelector("video") as Media;
|
||||
v._t = 700;
|
||||
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() =>
|
||||
hlsInstances[i]!.emit(Hls.Events.FRAG_LOADED, {
|
||||
frag: { type: "main", duration: 6, stats: { total: 1000 } },
|
||||
@@ -191,8 +194,30 @@ describe("an HLS session the server no longer has", () => {
|
||||
);
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
}
|
||||
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
|
||||
expect(session).toHaveBeenCalledTimes(5);
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("starts the budget over for a loss long after the last one", async () => {
|
||||
// The other half: a session reaped once an hour is not a session that cannot be served. The
|
||||
// window is what separates the two, so a spent budget must not follow the viewer around.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
|
||||
try {
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
act(() => void vi.setSystemTime(Date.now() + 10 * 60_000));
|
||||
}
|
||||
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
|
||||
expect(session).toHaveBeenCalledTimes(5);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("is a failure, not a restart, when the session never played", async () => {
|
||||
|
||||
Reference in New Issue
Block a user