Skip to content

Commit 8ae3146

Browse files
committed
fix: prefer WEB subtitle tracks
1 parent b28e315 commit 8ae3146

2 files changed

Lines changed: 84 additions & 15 deletions

File tree

src/youtube-caption-tracks.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type YoutubeInnertube,
77
} from "./youtube-innertube-session.ts";
88
import { buildYoutubeSabrPlayerRequest } from "./youtube-sabr-player-request.ts";
9+
import type { YoutubeSabrClient } from "./youtube-sabr-types.ts";
910

1011
export type RawCaptionTrack = {
1112
baseUrl?: string;
@@ -29,9 +30,9 @@ type CaptionPlayerResponse = {
2930
};
3031

3132
type CaptionTrackDependencies = {
32-
getInnertube(client: "MWEB", visitorData: string): Promise<YoutubeInnertube>;
33+
getInnertube(client: YoutubeSabrClient, visitorData: string): Promise<YoutubeInnertube>;
3334
invalidateInnertube(
34-
client: "MWEB",
35+
client: YoutubeSabrClient,
3536
visitorData: string,
3637
innertube: YoutubeInnertube,
3738
): Promise<void>;
@@ -54,24 +55,42 @@ export async function fetchCaptionTracks(
5455
playerPoToken: string,
5556
deps: CaptionTrackDependencies = dependencies,
5657
): Promise<RawCaptionTrack[]> {
57-
let innertube = await deps.getInnertube("MWEB", visitorData);
58+
const webTracks = await fetchCaptionTracksForClient(
59+
"WEB",
60+
videoId,
61+
visitorData,
62+
playerPoToken,
63+
deps,
64+
).catch(() => []);
65+
if (webTracks.length > 0) return webTracks;
66+
return fetchCaptionTracksForClient("MWEB", videoId, visitorData, playerPoToken, deps);
67+
}
68+
69+
async function fetchCaptionTracksForClient(
70+
client: YoutubeSabrClient,
71+
videoId: string,
72+
visitorData: string,
73+
playerPoToken: string,
74+
deps: CaptionTrackDependencies,
75+
): Promise<RawCaptionTrack[]> {
76+
let innertube = await deps.getInnertube(client, visitorData);
5877
let response = await deps.fetchPlayer(videoId, innertube, playerPoToken);
5978
if (
6079
isRejectedAnonymousSession(
6180
response.playability_status?.status,
6281
response.playability_status?.reason,
6382
)
6483
) {
65-
await deps.invalidateInnertube("MWEB", visitorData, innertube);
66-
innertube = await deps.getInnertube("MWEB", visitorData);
84+
await deps.invalidateInnertube(client, visitorData, innertube);
85+
innertube = await deps.getInnertube(client, visitorData);
6786
response = await deps.fetchPlayer(videoId, innertube, playerPoToken);
6887
}
6988
if (response.playability_status?.status !== "OK") {
7089
throw new Error(
71-
`YouTube MWEB player response is ${response.playability_status?.status ?? "missing"}: ${response.playability_status?.reason ?? "no reason"}`,
90+
`YouTube ${client} player response is ${response.playability_status?.status ?? "missing"}: ${response.playability_status?.reason ?? "no reason"}`,
7291
);
7392
}
74-
return (response.captions?.caption_tracks ?? []).map(toRawCaptionTrack);
93+
return (response.captions?.caption_tracks ?? []).map((track) => toRawCaptionTrack(track, client));
7594
}
7695

7796
async function fetchCaptionPlayer(
@@ -86,9 +105,12 @@ async function fetchCaptionPlayer(
86105
});
87106
}
88107

89-
function toRawCaptionTrack(track: CaptionTrackData): RawCaptionTrack {
108+
function toRawCaptionTrack(track: CaptionTrackData, client: YoutubeSabrClient): RawCaptionTrack {
90109
return {
91-
baseUrl: new URL(track.base_url, "https://m.youtube.com").toString(),
110+
baseUrl: new URL(
111+
track.base_url,
112+
client === "WEB" ? "https://www.youtube.com" : "https://m.youtube.com",
113+
).toString(),
92114
name: { simpleText: track.name.toString() },
93115
languageCode: track.language_code,
94116
kind: track.kind,

tests/youtube-caption-tracks.test.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const firstSession = { id: "first" } as unknown as YoutubeInnertube;
55
const secondSession = { id: "second" } as unknown as YoutubeInnertube;
66

77
describe("YouTube caption tracks", () => {
8-
it("loads and normalizes tracks through the MWEB player contract", async () => {
8+
it("loads and normalizes tracks through the WEB player contract", async () => {
99
const { fetchCaptionTracks } = await import(
1010
"../src/youtube-caption-tracks.ts?caption-contract"
1111
);
@@ -32,12 +32,12 @@ describe("YouTube caption tracks", () => {
3232
fetchPlayer,
3333
});
3434

35-
expect(getInnertube).toHaveBeenCalledWith("MWEB", "visitor");
35+
expect(getInnertube).toHaveBeenCalledWith("WEB", "visitor");
3636
expect(fetchPlayer).toHaveBeenCalledWith("video", firstSession, "visitor-pot");
3737
expect(invalidateInnertube).not.toHaveBeenCalled();
3838
expect(tracks).toEqual([
3939
{
40-
baseUrl: "https://m.youtube.com/api/timedtext?v=video&lang=en",
40+
baseUrl: "https://www.youtube.com/api/timedtext?v=video&lang=en",
4141
name: { simpleText: "English" },
4242
languageCode: "en",
4343
kind: undefined,
@@ -46,7 +46,42 @@ describe("YouTube caption tracks", () => {
4646
]);
4747
});
4848

49-
it("recreates a rejected anonymous MWEB session only once", async () => {
49+
it("falls back to MWEB when WEB does not return caption tracks", async () => {
50+
const { fetchCaptionTracks } = await import(
51+
"../src/youtube-caption-tracks.ts?caption-fallback"
52+
);
53+
const getInnertube = mock(async (client: "WEB" | "MWEB") =>
54+
client === "WEB" ? firstSession : secondSession,
55+
);
56+
const invalidateInnertube = mock(async () => undefined);
57+
const fetchPlayer = mock(async (_videoId: string, session: YoutubeInnertube) => ({
58+
playability_status: { status: "OK" },
59+
captions:
60+
session === secondSession
61+
? {
62+
caption_tracks: [
63+
{
64+
base_url: "/api/timedtext?v=video&lang=en",
65+
name: { toString: () => "English" },
66+
language_code: "en",
67+
vss_id: ".en",
68+
},
69+
],
70+
}
71+
: undefined,
72+
}));
73+
74+
const tracks = await fetchCaptionTracks("video", "visitor", "visitor-pot", {
75+
getInnertube,
76+
invalidateInnertube,
77+
fetchPlayer,
78+
});
79+
80+
expect(getInnertube.mock.calls.map(([client]) => client)).toEqual(["WEB", "MWEB"]);
81+
expect(tracks[0]?.baseUrl).toBe("https://m.youtube.com/api/timedtext?v=video&lang=en");
82+
});
83+
84+
it("recreates a rejected anonymous WEB session only once", async () => {
5085
const { fetchCaptionTracks } = await import(
5186
"../src/youtube-caption-tracks.ts?caption-contract"
5287
);
@@ -62,7 +97,19 @@ describe("YouTube caption tracks", () => {
6297
reason: "Sign in to confirm you are not a bot",
6398
},
6499
}
65-
: { playability_status: { status: "OK" } },
100+
: {
101+
playability_status: { status: "OK" },
102+
captions: {
103+
caption_tracks: [
104+
{
105+
base_url: "/api/timedtext?v=video&lang=en",
106+
name: { toString: () => "English" },
107+
language_code: "en",
108+
vss_id: ".en",
109+
},
110+
],
111+
},
112+
},
66113
);
67114

68115
await fetchCaptionTracks("video", "visitor", "visitor-pot", {
@@ -72,7 +119,7 @@ describe("YouTube caption tracks", () => {
72119
});
73120

74121
expect(getInnertube).toHaveBeenCalledTimes(2);
75-
expect(invalidateInnertube).toHaveBeenCalledWith("MWEB", "visitor", firstSession);
122+
expect(invalidateInnertube).toHaveBeenCalledWith("WEB", "visitor", firstSession);
76123
expect(fetchPlayer).toHaveBeenCalledTimes(2);
77124
});
78125
});

0 commit comments

Comments
 (0)