fix: refresh stale proxy token on video error

This commit is contained in:
2026-06-27 17:46:01 +02:00
parent 83dd4510df
commit 549f964e8e
3 changed files with 86 additions and 0 deletions

View File

@@ -243,6 +243,40 @@ describe("browser player flow", () => {
assert.equal(modules.streamUrlForMode("sub", "best"), ""); 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 () => { test("saves progress on pause and after scrub mouseup", async () => {
modules.setupProgress(); modules.setupProgress();
modules.state.elements.video.currentTime = 30; modules.state.elements.video.currentTime = 30;

View File

@@ -16,6 +16,7 @@ import { setupQuality, updateQualityOptions } from "./quality";
import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip"; import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip";
import { setupSegmentEditor } from "./skip/editor"; import { setupSegmentEditor } from "./skip/editor";
import { resolveActiveSegments, renderSegments } from "./skip/segments"; import { resolveActiveSegments, renderSegments } from "./skip/segments";
import { refreshCurrentModeSource } from "./source";
import { state, initState, showEndState, hideEndState } from "./state"; import { state, initState, showEndState, hideEndState } from "./state";
import { safeLocalStorage } from "./storage"; import { safeLocalStorage } from "./storage";
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles"; import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles";
@@ -280,6 +281,27 @@ const initPlayer = async (): Promise<void> => {
}, },
{ signal }, { 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 // update progress bar during buffering
state.elements.video.addEventListener( state.elements.video.addEventListener(
"progress", "progress",

View File

@@ -1,4 +1,7 @@
import { state } from "./state"; 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 => { export const streamUrlForMode = (mode: string, quality?: string): string => {
const src = state.playback.modeSources[mode]; const src = state.playback.modeSources[mode];
@@ -16,3 +19,30 @@ export const streamUrlForMode = (mode: string, quality?: string): string => {
return url; return url;
}; };
export const refreshCurrentModeSource = async (signal?: AbortSignal): Promise<boolean> => {
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;
};