Skip to content

Commit b3c247b

Browse files
committed
fix: preserve fresh WEB subtitle tracks
1 parent 8ae3146 commit b3c247b

2 files changed

Lines changed: 55 additions & 12 deletions

File tree

‎src/subtitle-content.ts‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,44 @@ export async function fetchSubtitleContent(
3232
fetchYoutube: typeof youtubeFetch = youtubeFetch,
3333
): Promise<Uint8Array> {
3434
const request = parseSubtitleRequest(rawUrl);
35+
try {
36+
return await fetchVttContent(
37+
refreshedVttUrl(rawUrl, request.translatedLanguageTag),
38+
fetchYoutube,
39+
);
40+
} catch (error) {
41+
if (error instanceof SubtitleFetchError && error.code === "subtitle_upstream_throttled") {
42+
throw error;
43+
}
44+
}
45+
3546
const { visitorData, poToken } = await fetchPoToken(request.videoId);
3647
const tracks = await fetchCaptionTracks(request.videoId, visitorData, poToken);
3748
const track = selectTrack(tracks, request);
3849
if (!track?.baseUrl) {
3950
throw new SubtitleFetchError("Subtitle track not found", "subtitle_track_not_found", 404);
4051
}
41-
const response = await fetchYoutube(
52+
return fetchVttContent(
4253
refreshedVttUrl(track.baseUrl, request.translatedLanguageTag),
43-
{
44-
headers: {
45-
accept: "text/vtt,*/*;q=0.8",
46-
origin: "https://m.youtube.com",
47-
referer: "https://m.youtube.com/",
48-
"user-agent": MWEB_USER_AGENT,
49-
"x-goog-visitor-id": visitorData,
50-
},
51-
},
54+
fetchYoutube,
55+
visitorData,
5256
);
57+
}
58+
59+
async function fetchVttContent(
60+
url: URL,
61+
fetchYoutube: typeof youtubeFetch,
62+
visitorData?: string,
63+
): Promise<Uint8Array> {
64+
const response = await fetchYoutube(url, {
65+
headers: {
66+
accept: "text/vtt,*/*;q=0.8",
67+
origin: "https://m.youtube.com",
68+
referer: "https://m.youtube.com/",
69+
"user-agent": MWEB_USER_AGENT,
70+
...(visitorData ? { "x-goog-visitor-id": visitorData } : {}),
71+
},
72+
});
5373
if (response.status === 429) {
5474
throw new SubtitleFetchError(
5575
"YouTube temporarily throttled subtitle retrieval",

‎tests/subtitles.test.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ describe("fetchSubtitles", () => {
114114
vssId: ".fr",
115115
},
116116
]);
117-
mockYoutubeFetch.mockImplementation(
117+
mockYoutubeFetch.mockImplementationOnce(async () => new Response("", { status: 403 }));
118+
mockYoutubeFetch.mockImplementationOnce(
118119
async () => new Response("WEBVTT\n\n00:00.000 --> 00:01.000\nBonjour"),
119120
);
120121
const { fetchSubtitleContent } = await import("../src/subtitle-content.ts");
@@ -126,6 +127,7 @@ describe("fetchSubtitles", () => {
126127
const requestHeaders = new Headers(mockYoutubeFetch.mock.calls.at(-1)?.[1]?.headers);
127128

128129
expect(new TextDecoder().decode(bytes).startsWith("WEBVTT")).toBe(true);
130+
expect(mockYoutubeFetch).toHaveBeenCalledTimes(2);
129131
expect(requested.searchParams.get("expire")).toBe("999");
130132
expect(requested.searchParams.get("fmt")).toBe("vtt");
131133
expect(requested.searchParams.get("tlang")).toBe("es");
@@ -139,7 +141,8 @@ describe("fetchSubtitles", () => {
139141
{ ...frTrack, languageCode: "en", vssId: ".en" },
140142
asrTrack,
141143
]);
142-
mockYoutubeFetch.mockImplementation(async () => new Response("WEBVTT\n\nAutomatic"));
144+
mockYoutubeFetch.mockImplementationOnce(async () => new Response("", { status: 403 }));
145+
mockYoutubeFetch.mockImplementationOnce(async () => new Response("WEBVTT\n\nAutomatic"));
143146
const { fetchSubtitleContent } = await import("../src/subtitle-content.ts");
144147
await fetchSubtitleContent(
145148
"https://www.youtube.com/api/timedtext?v=video&lang=en&kind=asr&expire=1",
@@ -151,6 +154,26 @@ describe("fetchSubtitles", () => {
151154
expect(requested.searchParams.get("expire")).toBe("999");
152155
});
153156

157+
it("uses a current WEB track without replacing it through MWEB", async () => {
158+
mockFetchCaptionTracks.mockClear();
159+
mockYoutubeFetch.mockClear();
160+
mockYoutubeFetch.mockImplementationOnce(
161+
async () => new Response("WEBVTT\n\n00:00.000 --> 00:01.000\nCurrent"),
162+
);
163+
const { fetchSubtitleContent } = await import("../src/subtitle-content.ts");
164+
const bytes = await fetchSubtitleContent(
165+
"https://www.youtube.com/api/timedtext?v=video&lang=en&expire=999&fmt=ttml",
166+
mockYoutubeFetch,
167+
);
168+
const requested = new URL(String(mockYoutubeFetch.mock.calls[0]?.[0]));
169+
170+
expect(new TextDecoder().decode(bytes).startsWith("WEBVTT")).toBe(true);
171+
expect(requested.hostname).toBe("www.youtube.com");
172+
expect(requested.searchParams.get("fmt")).toBe("vtt");
173+
expect(mockFetchCaptionTracks).not.toHaveBeenCalled();
174+
expect(mockYoutubeFetch).toHaveBeenCalledTimes(1);
175+
});
176+
154177
it("returns a typed throttling failure without retrying", async () => {
155178
mockFetchCaptionTracks.mockImplementation(async () => [asrTrack]);
156179
mockYoutubeFetch.mockClear();

0 commit comments

Comments
 (0)