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:
@@ -21,6 +21,13 @@ import { plexLang } from "../i18n";
|
||||
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
||||
|
||||
// How close to the end the backend counts as "finished" (`_FINISH_MARGIN_S` in routes/plex/playback):
|
||||
// a progress checkpoint at or past `duration - this` marks the item watched and clears the position.
|
||||
// Mirrored here because `finish`'s rollback must not accidentally drive that branch. It is a
|
||||
// one-way safety margin, not a shared contract — if the two ever diverge, the worst case is a
|
||||
// rollback that is skipped or a resume point set a few seconds from the end.
|
||||
const FINISH_MARGIN_S = 30;
|
||||
|
||||
// A live wall clock for the top bar, re-rendered every 15s (minute resolution is enough for the
|
||||
// HH:MM display, and a coarse tick keeps the whole player from re-rendering each second).
|
||||
export function useWallClock(): Date {
|
||||
@@ -186,7 +193,15 @@ export function usePlexProgress(opts: {
|
||||
const dur = durationRef.current;
|
||||
void confirmed?.catch(() => {
|
||||
finished = false;
|
||||
if (pos > 0 && dur > 0) api.plexProgress(id, pos, dur, true).catch(() => {});
|
||||
// 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
|
||||
// position within `_FINISH_MARGIN_S` of the duration as a finish: it would set watched,
|
||||
// clear the position and scrobble to Plex. So the branch named "take the finish back"
|
||||
// performed, through a second route, exactly the write that had just been refused — while
|
||||
// the caller's error toast was already on screen saying it had failed (round 6). The
|
||||
// credits-skip caller captures a mid-episode position and is unaffected.
|
||||
if (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.
|
||||
|
||||
Reference in New Issue
Block a user