Skip to content

Commit b2dfc3c

Browse files
authored
fix: recover invalid sabr sessions (#17)
## Summary - recognize terminal SABR context recovery without changing selected formats - recreate one fresh playback session at the current position - publish the engine as 0.1.20 ## Verification - `bun run check` - `bun test` - `bun run test:coverage` - `bun run build` - `bun run check:versions` - `bun run check:docs` - `bun run check:jsr`
1 parent 1aa2d4b commit b2dfc3c

7 files changed

Lines changed: 117 additions & 5 deletions

File tree

jsr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://jsr.io/schema/config-file.v1.json",
33
"name": "@typetype/mse",
4-
"version": "0.1.19",
4+
"version": "0.1.20",
55
"exports": "./src/index.ts",
66
"publish": {
77
"include": ["LICENSE", "README.md", "src/**/*.ts"]

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@typetype/mse",
3-
"version": "0.1.19",
3+
"version": "0.1.20",
44
"description": "MSE playback engine for TypeType",
55
"license": "MIT",
66
"type": "module",

src/playback-window.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export type PlaybackWindowRequest = {
2323
};
2424

2525
/** Recovery operation requested when the current playback session becomes terminal. */
26-
export type PlaybackWindowRecoveryAction = "retry_fresh_session_lower_video_itag";
26+
export type PlaybackWindowRecoveryAction =
27+
| "retry_fresh_session"
28+
| "retry_fresh_session_lower_video_itag";
2729

2830
/** Parsed backend response containing media tracks or a retry instruction. */
2931
export type PlaybackWindow = {
@@ -70,7 +72,9 @@ function arrayField(value: object, key: string): unknown[] {
7072

7173
function recoveryActionField(value: object): PlaybackWindowRecoveryAction | null {
7274
const result = field(value, "recoveryAction");
73-
return result === "retry_fresh_session_lower_video_itag" ? result : null;
75+
return result === "retry_fresh_session" || result === "retry_fresh_session_lower_video_itag"
76+
? result
77+
: null;
7478
}
7579

7680
function integerArrayField(value: object, key: string): number[] {

src/player-session-loader.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export async function loadPlayerSession(args: Args): Promise<LoadedSession> {
3737
return await loadSelectedSession(args, args.response, selection);
3838
} catch (error) {
3939
if (!(error instanceof PlaybackWindowRecoveryError)) throw error;
40+
if (error.recoveryAction === "retry_fresh_session") {
41+
return recoverWithFreshSession(args, selection);
42+
}
4043
return recoverWithFreshSessions(args, selection, error.retryVideoItags);
4144
}
4245
}
@@ -109,6 +112,24 @@ async function recoverWithFreshSessions(
109112
throw new Error("Playback window recovery failed");
110113
}
111114

115+
async function recoverWithFreshSession(
116+
args: Args,
117+
selection: TrackSelection,
118+
): Promise<LoadedSession> {
119+
const response = await args.deps.playback.create(
120+
{
121+
videoId: args.config.videoId,
122+
videoItag: selection.videoItag,
123+
audioItag: selection.audioItag,
124+
audioTrackId: selection.audioTrackId,
125+
startTimeMs: args.startTimeMs,
126+
audioOnly: args.config.audioOnly === true,
127+
},
128+
args.signal,
129+
);
130+
return loadSelectedSession(args, response, selection);
131+
}
132+
112133
function isAbortError(error: unknown): boolean {
113134
return error instanceof DOMException && error.name === "AbortError";
114135
}

src/session-loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ function retryDelay(
192192

193193
function handleWindow(window: Awaited<ReturnType<PlaybackClient["segments"]>>) {
194194
if (!window.terminalError) return window;
195-
if (window.recoveryAction && window.retryVideoItags.length > 0) {
195+
if (window.recoveryAction) {
196196
throw new PlaybackWindowRecoveryError(
197197
window.terminalError,
198198
window.recoveryAction,

tests/playback-window.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ test("parses playback window recovery hints", () => {
6060
expect(window.retryVideoItags).toEqual([136, 135, 134]);
6161
});
6262

63+
test("parses fresh session recovery without changing formats", () => {
64+
const window = parsePlaybackWindow(
65+
{
66+
sessionId: "session",
67+
generation: 1,
68+
ready: false,
69+
terminalError: "Expected UMP response, got content type: text/plain",
70+
recoveryAction: "retry_fresh_session",
71+
retryVideoItags: [],
72+
},
73+
"https://beta.typetype.video/api/sabr/playback/session/window",
74+
);
75+
expect(window.recoveryAction).toBe("retry_fresh_session");
76+
expect(window.retryVideoItags).toEqual([]);
77+
});
78+
6379
test("parses audio-only playback windows", () => {
6480
const window = parsePlaybackWindow(
6581
{

tests/player-session-loader.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,74 @@ test("recovers terminal seek windows with a fresh lower video itag session", asy
160160
expect(positionRequests[0]).toEqual([]);
161161
expect(filledWindows).toEqual([[120_000, 59_000, 90_000]]);
162162
});
163+
164+
test("recovers invalid sabr context with a fresh session using the same formats", async () => {
165+
const created: number[] = [];
166+
const session = await loadPlayerSession({
167+
deps: {
168+
playback: {
169+
create: async (request) => {
170+
created.push(request.videoItag);
171+
return response("fresh-session", request.videoId);
172+
},
173+
position: async (sessionId, request) => window(sessionId, request.generation, false),
174+
prefetch: async (sessionId, request) =>
175+
sessionId === "stale-session"
176+
? {
177+
...window(sessionId, request.generation, false),
178+
terminalError: "Expected UMP response, got content type: text/plain",
179+
recoveryAction: "retry_fresh_session",
180+
}
181+
: { ...window(sessionId, request.generation, true), manifest },
182+
segments: async (sessionId, request) => ({
183+
...window(sessionId, request.generation, true),
184+
manifest,
185+
}),
186+
},
187+
media: { attach: async () => undefined, bufferedRanges: () => [] },
188+
scheduler: {
189+
reset: () => undefined,
190+
appendInit: async () => undefined,
191+
fill: async () => undefined,
192+
},
193+
policy: {
194+
bufferGoalMs: 30_000,
195+
backBufferMs: 30_000,
196+
pollIntervalMs: 500,
197+
manifestRefreshMs: 8_000,
198+
manifestPollLimit: 2,
199+
segmentPollLimit: 2,
200+
},
201+
},
202+
config: {
203+
endpoint: "https://beta.typetype.video/api",
204+
videoId: "Vj6ReOur1Kk",
205+
videoItag: 137,
206+
audioItag: 140,
207+
audioTrackId: "fr-FR.4",
208+
},
209+
video: { currentTime: 399.383 },
210+
response: response("stale-session", "Vj6ReOur1Kk"),
211+
current: null,
212+
quality: undefined,
213+
startTimeMs: 399_383,
214+
signal: new AbortController().signal,
215+
});
216+
217+
expect(created).toEqual([137]);
218+
expect(session.response.sessionId).toBe("fresh-session");
219+
expect(session.videoItag).toBe(137);
220+
});
221+
222+
function window(sessionId: string, generation: number | null, ready: boolean): PlaybackWindow {
223+
return {
224+
sessionId,
225+
generation,
226+
ready,
227+
retryAfterMs: null,
228+
terminalError: null,
229+
recoveryAction: null,
230+
retryVideoItags: [],
231+
manifest: null,
232+
};
233+
}

0 commit comments

Comments
 (0)