From 549f964e8e221e0651e98852e93c1335c9afc29a Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 27 Jun 2026 17:46:01 +0200 Subject: [PATCH] fix: refresh stale proxy token on video error --- static/player/browser-flow.test.ts | 34 ++++++++++++++++++++++++++++++ static/player/main.ts | 22 +++++++++++++++++++ static/player/source.ts | 30 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/static/player/browser-flow.test.ts b/static/player/browser-flow.test.ts index dbd111e2..f1416b03 100644 --- a/static/player/browser-flow.test.ts +++ b/static/player/browser-flow.test.ts @@ -243,6 +243,40 @@ describe("browser player flow", () => { assert.equal(modules.streamUrlForMode("sub", "best"), ""); }); + test("refreshes stale current stream token from episode data", async () => { + modules.state.elements.video.currentTime = 64; + modules.state.playback.modeSources = { + sub: { token: "stale-token", type: "m3u8", qualities: ["720p"], subtitles: [] }, + }; + globalThis.fetch = ((url: string) => { + fetchCalls.push({ url }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + mode_sources: { + sub: { token: "fresh-token", type: "m3u8", qualities: ["720p"], subtitles: [] }, + }, + }), + } as Response); + }) as typeof fetch; + + const refreshed = await modules.refreshCurrentModeSource(); + + assert.equal(refreshed, true); + assert.deepEqual( + fetchCalls.map((call) => call.url), + ["/api/watch/episode/42/3?mode=sub"], + ); + assert.equal(modules.state.playback.modeSources.sub?.token, "fresh-token"); + assert.equal( + modules.state.elements.video.src, + "/watch/proxy/stream?mode=sub&token=fresh-token&hls=1", + ); + assert.equal(modules.state.playback.pendingSeekTime, 64); + }); + test("saves progress on pause and after scrub mouseup", async () => { modules.setupProgress(); modules.state.elements.video.currentTime = 30; diff --git a/static/player/main.ts b/static/player/main.ts index 579f052a..c5f7c318 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -16,6 +16,7 @@ import { setupQuality, updateQualityOptions } from "./quality"; import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip"; import { setupSegmentEditor } from "./skip/editor"; import { resolveActiveSegments, renderSegments } from "./skip/segments"; +import { refreshCurrentModeSource } from "./source"; import { state, initState, showEndState, hideEndState } from "./state"; import { safeLocalStorage } from "./storage"; import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles"; @@ -280,6 +281,27 @@ const initPlayer = async (): Promise => { }, { signal }, ); + let sourceRefreshInFlight = false; + state.elements.video.addEventListener( + "error", + () => { + if (sourceRefreshInFlight) { + return; + } + sourceRefreshInFlight = true; + refreshCurrentModeSource(signal) + .catch((error) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + console.error("failed to refresh video source:", error); + }) + .finally(() => { + sourceRefreshInFlight = false; + }); + }, + { signal }, + ); // update progress bar during buffering state.elements.video.addEventListener( "progress", diff --git a/static/player/source.ts b/static/player/source.ts index 026a4e42..4cabca7d 100644 --- a/static/player/source.ts +++ b/static/player/source.ts @@ -1,4 +1,7 @@ import { state } from "./state"; +import { safeLocalStorage } from "./storage"; +import { isRecord, parseModeSources } from "./validate"; +import { loadVideoSource } from "./video"; export const streamUrlForMode = (mode: string, quality?: string): string => { const src = state.playback.modeSources[mode]; @@ -16,3 +19,30 @@ export const streamUrlForMode = (mode: string, quality?: string): string => { return url; }; + +export const refreshCurrentModeSource = async (signal?: AbortSignal): Promise => { + const mode = state.playback.currentMode; + const res = await fetch( + `/api/watch/episode/${state.episode.malID}/${encodeURIComponent(state.episode.current)}?mode=${encodeURIComponent(mode)}`, + { signal }, + ); + if (!res.ok) { + throw new Error(`mode source refresh failed with status ${res.status}`); + } + + const data: unknown = await res.json(); + if (!isRecord(data)) { + return false; + } + + const refreshedSource = parseModeSources(data.mode_sources)[mode]; + if (!refreshedSource?.token) { + return false; + } + + state.playback.modeSources = { ...state.playback.modeSources, [mode]: refreshedSource }; + + const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best"; + loadVideoSource(streamUrlForMode(mode, preferredQuality), refreshedSource.type); + return true; +};