From 2df45b27d3ad258e5c29a718b1068803ed5f0979 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 06:58:11 +0200 Subject: [PATCH] fix(plex): a refused finish restores no resume point at the media's own end The rollback of a refused watched mark guarded against Plex's REPORTED duration. On a file shorter than its metadata the end sits outside the finish margin, so playing to the end and having the mark refused stored the very end as a resume point on an unwatched row: a ~99% bar that reopened in the last seconds. The media's own ended flag now decides. --- .../components/PlexPlayer.session.dom.test.tsx | 18 ++++++++++++++++++ frontend/src/lib/plexPlayerHooks.ts | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/PlexPlayer.session.dom.test.tsx b/frontend/src/components/PlexPlayer.session.dom.test.tsx index 5593d358..d88d0a16 100644 --- a/frontend/src/components/PlexPlayer.session.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.session.dom.test.tsx @@ -420,6 +420,24 @@ describe("leaving an item that was played through", () => { expect(qc.getQueryData(qk.plexItem("A", "en"))?.status).not.toBe("watched"); }); + it("restores nothing when the media ended short of the duration Plex reports", async () => { + // Plex's metadata can overrun the file. Ending at 2900 of a reported 3000 is outside the finish + // margin, so the duration-based guard alone wrote 2900 back as the resume point: an unwatched + // item with a ~99% bar that reopened in its last seconds. The media's own `ended` decides. + prefs({ upNext: false }); + const { progress } = mount({ + details: { A: item("A"), 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", 2900); + endMedia(); + await screen.findByText("Title B"); + await tick(); + 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, diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 90020698..82f002f7 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -215,6 +215,10 @@ export function usePlexProgress(opts: { // Watched badge the server disagreed with (round 5). const pos = Math.floor(absRef.current); const dur = durationRef.current; + // The media's own end, not Plex's reported duration: a file shorter than its metadata (a + // configuration this file documents) ends outside the margin below, and restoring THAT + // position left an unwatched item with a ~99% bar that reopened in its last seconds. + const ended = video?.ended === true; void confirmed?.catch(() => { finishRefused = true; // Restore the resume point the finish replaced — but ONLY where there is one to restore. @@ -224,7 +228,7 @@ export function usePlexProgress(opts: { // 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) + 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