From 6e69bca0bcc274806ae136feaf932982efb4d826 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 13:22:51 +0200 Subject: [PATCH 01/10] chore: prepare token 1.3.1 beta --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 01017e4..b92fac8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.2.4", + "version": "1.3.1", "private": true, "license": "MIT", "scripts": { From f16a0ba50a9c13748febe69018b2987335b33521 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 13:34:10 +0200 Subject: [PATCH 02/10] fix: align token beta version --- .github/workflows/docker.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 345aecb..400b58b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -71,6 +71,11 @@ jobs: if [[ "$GITHUB_REF_NAME" == "main" ]]; then version="$base_version" elif [[ "$GITHUB_REF_NAME" == "dev" ]]; then + base_version="$(bun -e 'import packageJson from "./package.json"; console.log(packageJson.version)')" + if [[ ! "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid development version: $base_version" + exit 1 + fi version="$base_version-dev.$GITHUB_RUN_NUMBER" elif [[ "$GITHUB_REF" != refs/tags/v* ]]; then channel="${GITHUB_REF_NAME//[^0-9A-Za-z-]/-}" From 27d48b7ef59d5ca6c38bb1ed10c2c48999dae574 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 09:39:07 +0200 Subject: [PATCH 03/10] fix: proxy YouTube subtitle content --- src/index.ts | 20 +---- src/innertube.ts | 5 +- src/subtitle-content.ts | 154 ++++++++++++++++++++++++++++++++++ src/subtitle-routes.ts | 49 +++++++++++ src/subtitles.ts | 3 +- src/youtube-client-profile.ts | 3 + 6 files changed, 212 insertions(+), 22 deletions(-) create mode 100644 src/subtitle-content.ts create mode 100644 src/subtitle-routes.ts create mode 100644 src/youtube-client-profile.ts diff --git a/src/index.ts b/src/index.ts index 62fb951..eb9466d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { createRemoteLoginHandler, type RemoteLoginWebSocketData, } from "./remote-login-routes.ts"; -import { fetchSubtitles } from "./subtitles.ts"; +import { handleSubtitleRequest } from "./subtitle-routes.ts"; import { fetchPoToken } from "./token-service.ts"; import { decodeYoutubePlayerBatch } from "./youtube-player-decoder.ts"; import { fetchYoutubeSabrSession } from "./youtube-sabr-session.ts"; @@ -25,6 +25,8 @@ export async function handler( const url = new URL(req.url); const remoteLoginResponse = await remoteLoginHandler(req, url, server); if (remoteLoginResponse !== null) return remoteLoginResponse; + const subtitleResponse = await handleSubtitleRequest(req, url); + if (subtitleResponse !== null) return subtitleResponse; if (req.method === "GET" && url.pathname === "/potoken") { const videoId = url.searchParams.get("videoId"); @@ -46,22 +48,6 @@ export async function handler( } } - if (req.method === "GET" && url.pathname === "/subtitles") { - const videoId = url.searchParams.get("videoId"); - - if (!videoId) { - return Response.json({ error: "videoId query parameter is required" }, { status: 400 }); - } - - try { - const tracks = await fetchSubtitles(videoId); - return Response.json(tracks); - } catch (error) { - const message = error instanceof Error ? error.message : "Internal error"; - return Response.json({ error: message }, { status: 500 }); - } - } - if (req.method === "GET" && url.pathname === "/youtube/sabr/session") { const videoId = url.searchParams.get("videoId"); const clientParam = url.searchParams.get("client") ?? "MWEB"; diff --git a/src/innertube.ts b/src/innertube.ts index 8015dc0..edcf94f 100644 --- a/src/innertube.ts +++ b/src/innertube.ts @@ -1,5 +1,6 @@ import type { IntegrityTokenData } from "bgutils-js"; import { WEB_CLIENT_VERSION } from "./botguard-challenge.ts"; +import { ANDROID_VR_USER_AGENT } from "./youtube-client-profile.ts"; import { youtubeFetch } from "./youtube-fetch.ts"; const INNERTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; @@ -10,9 +11,6 @@ const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36"; -const ANDROID_VR_USER_AGENT = - "com.google.android.apps.youtube.vr.oculus/1.65.10 " + - "(Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip"; const ANDROID_VR_CLIENT_VERSION = "1.65.10"; const ANDROID_VR_CLIENT_NAME_ID = "28"; @@ -61,6 +59,7 @@ export type RawCaptionTrack = { name?: { simpleText?: string; runs?: { text?: string }[] }; languageCode?: string; kind?: string; + vssId?: string; }; type PlayerResponse = { diff --git a/src/subtitle-content.ts b/src/subtitle-content.ts new file mode 100644 index 0000000..e127d20 --- /dev/null +++ b/src/subtitle-content.ts @@ -0,0 +1,154 @@ +import { fetchCaptionTracks, type RawCaptionTrack } from "./innertube.ts"; +import { fetchPoToken } from "./token-service.ts"; +import { ANDROID_VR_USER_AGENT } from "./youtube-client-profile.ts"; +import { youtubeFetch } from "./youtube-fetch.ts"; + +export type SubtitleErrorCode = + | "subtitle_request_invalid" + | "subtitle_track_not_found" + | "subtitle_upstream_throttled" + | "subtitle_upstream_unavailable" + | "subtitle_payload_invalid"; + +export class SubtitleFetchError extends Error { + constructor( + message: string, + readonly code: SubtitleErrorCode, + readonly status: number, + ) { + super(message); + } +} + +type SubtitleRequest = { + videoId: string; + languageTag: string; + autoGenerated: boolean | null; + translatedLanguageTag: string | null; +}; + +export async function fetchSubtitleContent( + rawUrl: string, + fetchYoutube: typeof youtubeFetch = youtubeFetch, +): Promise { + const request = parseSubtitleRequest(rawUrl); + const { visitorData, streamingPot } = await fetchPoToken(request.videoId); + const tracks = await fetchCaptionTracks(request.videoId, visitorData, streamingPot); + const track = selectTrack(tracks, request); + if (!track?.baseUrl) { + throw new SubtitleFetchError("Subtitle track not found", "subtitle_track_not_found", 404); + } + const response = await fetchYoutube( + refreshedVttUrl(track.baseUrl, request.translatedLanguageTag), + { + headers: { + accept: "text/vtt,*/*;q=0.8", + origin: "https://www.youtube.com", + "user-agent": ANDROID_VR_USER_AGENT, + "x-goog-visitor-id": visitorData, + }, + }, + ); + if (response.status === 429) { + throw new SubtitleFetchError( + "YouTube temporarily throttled subtitle retrieval", + "subtitle_upstream_throttled", + 429, + ); + } + if (!response.ok) { + throw new SubtitleFetchError( + `YouTube subtitle request failed: ${response.status}`, + "subtitle_upstream_unavailable", + 502, + ); + } + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_SUBTITLE_BYTES) throw invalidPayload(); + const bytes = new Uint8Array(await response.arrayBuffer()); + if ( + bytes.length > MAX_SUBTITLE_BYTES || + !new TextDecoder().decode(bytes).trimStart().startsWith("WEBVTT") + ) { + throw invalidPayload(); + } + return bytes; +} + +function parseSubtitleRequest(rawUrl: string): SubtitleRequest { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw invalidRequest(); + } + if ( + url.protocol !== "https:" || + !isYoutubeHost(url.hostname) || + url.pathname !== "/api/timedtext" + ) { + throw invalidRequest(); + } + const videoId = url.searchParams.get("v")?.trim() ?? ""; + const languageTag = url.searchParams.get("lang")?.trim() ?? ""; + const translatedLanguageTag = url.searchParams.get("tlang")?.trim() || null; + if (!videoId || videoId.length > 128 || !languageTag || languageTag.length > 64) + throw invalidRequest(); + if (translatedLanguageTag && translatedLanguageTag.length > 64) throw invalidRequest(); + const vssId = url.searchParams.get("vssId"); + const kind = url.searchParams.get("kind"); + return { + videoId, + languageTag, + autoGenerated: + kind === null && vssId === null ? null : kind === "asr" || vssId?.startsWith("a.") === true, + translatedLanguageTag, + }; +} + +function selectTrack( + tracks: RawCaptionTrack[], + request: SubtitleRequest, +): RawCaptionTrack | undefined { + const matchingLanguage = tracks.filter((track) => track.languageCode === request.languageTag); + if (request.autoGenerated === null) { + return matchingLanguage.find((track) => !isAutoGenerated(track)) ?? matchingLanguage[0]; + } + return matchingLanguage.find((track) => isAutoGenerated(track) === request.autoGenerated); +} + +function refreshedVttUrl(baseUrl: string, translatedLanguageTag: string | null): URL { + const url = new URL(baseUrl, "https://www.youtube.com"); + if ( + url.protocol !== "https:" || + !isYoutubeHost(url.hostname) || + url.pathname !== "/api/timedtext" + ) + throw invalidRequest(); + url.searchParams.set("fmt", "vtt"); + if (translatedLanguageTag) url.searchParams.set("tlang", translatedLanguageTag); + else url.searchParams.delete("tlang"); + return url; +} + +function isYoutubeHost(host: string): boolean { + return host === "youtube.com" || host.endsWith(".youtube.com"); +} + +function isAutoGenerated(track: RawCaptionTrack): boolean { + return track.kind === "asr" || track.vssId?.startsWith("a.") === true; +} + +function invalidRequest(): SubtitleFetchError { + return new SubtitleFetchError("Invalid YouTube subtitle URL", "subtitle_request_invalid", 400); +} + +function invalidPayload(): SubtitleFetchError { + return new SubtitleFetchError( + "YouTube returned invalid subtitle content", + "subtitle_payload_invalid", + 502, + ); +} + +const MAX_SUBTITLE_BYTES = 5 * 1024 * 1024; diff --git a/src/subtitle-routes.ts b/src/subtitle-routes.ts new file mode 100644 index 0000000..47a8c7d --- /dev/null +++ b/src/subtitle-routes.ts @@ -0,0 +1,49 @@ +import { fetchSubtitleContent, SubtitleFetchError } from "./subtitle-content.ts"; +import { fetchSubtitles } from "./subtitles.ts"; + +export async function handleSubtitleRequest(req: Request, url: URL): Promise { + if (req.method !== "GET") return null; + if (url.pathname === "/subtitles") return subtitleInventory(url); + if (url.pathname === "/subtitles/content") return subtitleContent(url); + return null; +} + +async function subtitleInventory(url: URL): Promise { + const videoId = url.searchParams.get("videoId"); + if (!videoId) { + return Response.json({ error: "videoId query parameter is required" }, { status: 400 }); + } + try { + return Response.json(await fetchSubtitles(videoId)); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + return Response.json({ error: message }, { status: 500 }); + } +} + +async function subtitleContent(url: URL): Promise { + const rawUrl = url.searchParams.get("url"); + if (!rawUrl) { + return Response.json( + { error: "url query parameter is required", code: "subtitle_request_invalid" }, + { status: 400 }, + ); + } + try { + return new Response(await fetchSubtitleContent(rawUrl), { + headers: { + "cache-control": "private, max-age=300", + "content-type": "text/vtt; charset=utf-8", + }, + }); + } catch (error) { + if (error instanceof SubtitleFetchError) { + return Response.json({ error: error.message, code: error.code }, { status: error.status }); + } + const message = error instanceof Error ? error.message : "Internal error"; + return Response.json( + { error: message, code: "subtitle_upstream_unavailable" }, + { status: 502 }, + ); + } +} diff --git a/src/subtitles.ts b/src/subtitles.ts index a2de14b..f78ae66 100644 --- a/src/subtitles.ts +++ b/src/subtitles.ts @@ -1,5 +1,4 @@ -import type { RawCaptionTrack } from "./innertube.ts"; -import { fetchCaptionTracks } from "./innertube.ts"; +import { fetchCaptionTracks, type RawCaptionTrack } from "./innertube.ts"; import { fetchPoToken } from "./token-service.ts"; export type SubtitleTrack = { diff --git a/src/youtube-client-profile.ts b/src/youtube-client-profile.ts new file mode 100644 index 0000000..83f8d72 --- /dev/null +++ b/src/youtube-client-profile.ts @@ -0,0 +1,3 @@ +export const ANDROID_VR_USER_AGENT = + "com.google.android.apps.youtube.vr.oculus/1.65.10 " + + "(Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip"; From a05bc038aabd6d9a98ae946c2123a808a9f8a667 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 09:39:10 +0200 Subject: [PATCH 04/10] test: cover YouTube subtitle retrieval --- tests/index.test.ts | 11 ++++++ tests/subtitles.test.ts | 85 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/tests/index.test.ts b/tests/index.test.ts index ed51008..3b7e88a 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -154,6 +154,17 @@ describe("handler", () => { expect(Array.isArray(body)).toBe(true); }); + it("GET /subtitles/content without url returns a typed 400", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler(new Request("http://localhost:8081/subtitles/content")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "url query parameter is required", + code: "subtitle_request_invalid", + }); + }); + it("GET /youtube/sabr/session without videoId returns 400", async () => { const { handler } = await import("../src/index.ts"); const res = await handler(new Request("http://localhost:8081/youtube/sabr/session")); diff --git a/tests/subtitles.test.ts b/tests/subtitles.test.ts index 103d67c..a94cc98 100644 --- a/tests/subtitles.test.ts +++ b/tests/subtitles.test.ts @@ -4,6 +4,13 @@ import type { SubtitleTrack } from "../src/subtitles.ts"; const VISITOR_DATA = "visitor-data-test-123"; const INTEGRITY_TOKEN = "integrity-token-test-xyz"; const PO_TOKEN = `pot-${VISITOR_DATA}`; +const mockYoutubeFetch = mock( + async (_input: RequestInfo | URL): Promise => + new Response("WEBVTT\n\n00:00.000 --> 00:01.000\nHello", { + status: 200, + headers: { "content-type": "text/vtt" }, + }), +); mock.module("../src/botguard-page.ts", () => ({ executeBotGuard: mock(async (): Promise => "botguard-response-test"), @@ -34,10 +41,11 @@ const frTrack = { }; const asrTrack = { - baseUrl: "https://www.youtube.com/api/timedtext?lang=en", + baseUrl: "https://www.youtube.com/api/timedtext?v=video&lang=en&kind=asr&expire=999", name: { simpleText: "English (auto-generated)" }, languageCode: "en", kind: "asr", + vssId: "a.en", }; const mockFetchCaptionTracks = mock( @@ -67,7 +75,7 @@ describe("fetchSubtitles", () => { }); expect(result[1]).toEqual({ - url: "https://www.youtube.com/api/timedtext?lang=en", + url: "https://www.youtube.com/api/timedtext?v=video&lang=en&kind=asr&expire=999", mimeType: "application/ttml+xml", languageTag: "en", displayLanguageName: "English (auto-generated)", @@ -92,4 +100,77 @@ describe("fetchSubtitles", () => { expect(result).toHaveLength(1); expect(result[0].languageTag).toBe("fr"); }); + + it("refreshes an expired manual track and preserves translation", async () => { + mockFetchCaptionTracks.mockImplementation(async () => [ + { + ...frTrack, + baseUrl: "https://www.youtube.com/api/timedtext?v=video&lang=fr&expire=999", + vssId: ".fr", + }, + ]); + mockYoutubeFetch.mockImplementation( + async () => new Response("WEBVTT\n\n00:00.000 --> 00:01.000\nBonjour"), + ); + const { fetchSubtitleContent } = await import("../src/subtitle-content.ts"); + const bytes = await fetchSubtitleContent( + "https://www.youtube.com/api/timedtext?v=video&lang=fr&expire=1&tlang=es&fmt=ttml", + mockYoutubeFetch, + ); + const requested = new URL(String(mockYoutubeFetch.mock.calls.at(-1)?.[0])); + + expect(new TextDecoder().decode(bytes).startsWith("WEBVTT")).toBe(true); + expect(requested.searchParams.get("expire")).toBe("999"); + expect(requested.searchParams.get("fmt")).toBe("vtt"); + expect(requested.searchParams.get("tlang")).toBe("es"); + }); + + it("selects the refreshed automatic track", async () => { + mockFetchCaptionTracks.mockImplementation(async () => [ + { ...frTrack, languageCode: "en", vssId: ".en" }, + asrTrack, + ]); + mockYoutubeFetch.mockImplementation(async () => new Response("WEBVTT\n\nAutomatic")); + const { fetchSubtitleContent } = await import("../src/subtitle-content.ts"); + await fetchSubtitleContent( + "https://www.youtube.com/api/timedtext?v=video&lang=en&kind=asr&expire=1", + mockYoutubeFetch, + ); + const requested = new URL(String(mockYoutubeFetch.mock.calls.at(-1)?.[0])); + + expect(requested.searchParams.get("kind")).toBe("asr"); + expect(requested.searchParams.get("expire")).toBe("999"); + }); + + it("returns a typed throttling failure without retrying", async () => { + mockFetchCaptionTracks.mockImplementation(async () => [asrTrack]); + mockYoutubeFetch.mockClear(); + mockYoutubeFetch.mockImplementation(async () => new Response("", { status: 429 })); + const { fetchSubtitleContent, SubtitleFetchError } = await import("../src/subtitle-content.ts"); + const failure = await fetchSubtitleContent( + "https://www.youtube.com/api/timedtext?v=video&lang=en&kind=asr", + mockYoutubeFetch, + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(SubtitleFetchError); + expect((failure as InstanceType).code).toBe( + "subtitle_upstream_throttled", + ); + expect(mockYoutubeFetch).toHaveBeenCalledTimes(1); + }); + + it("rejects non-YouTube subtitle URLs before fetching", async () => { + mockYoutubeFetch.mockClear(); + const { fetchSubtitleContent, SubtitleFetchError } = await import("../src/subtitle-content.ts"); + const failure = await fetchSubtitleContent( + "https://example.com/api/timedtext?v=video&lang=en", + mockYoutubeFetch, + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(SubtitleFetchError); + expect((failure as InstanceType).code).toBe( + "subtitle_request_invalid", + ); + expect(mockYoutubeFetch).not.toHaveBeenCalled(); + }); }); From bab89e4003a464b424ee3f928afab8ae96646365 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 09:44:04 +0200 Subject: [PATCH 05/10] test: isolate Innertube module mocks --- tests/token-service-refresh.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/token-service-refresh.test.ts b/tests/token-service-refresh.test.ts index 42fc485..5fe426a 100644 --- a/tests/token-service-refresh.test.ts +++ b/tests/token-service-refresh.test.ts @@ -60,6 +60,7 @@ mock.module("../src/botguard-challenge.ts", () => ({ })); mock.module("../src/innertube.ts", () => ({ + fetchCaptionTracks: mock(async () => []), fetchVisitorData: mock(async (): Promise => { visitorDataRequests += 1; if (visitorDataRequests === 1) { From b28e315c09cd6f38b08f9e348392a36ce1cf99e1 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 16:25:41 +0200 Subject: [PATCH 06/10] fix: use MWEB for YouTube subtitles --- src/innertube.ts | 67 ------------------- src/subtitle-content.ts | 15 +++-- src/subtitles.ts | 6 +- src/youtube-caption-tracks.ts | 97 ++++++++++++++++++++++++++++ src/youtube-client-profile.ts | 3 - src/youtube-mweb-config.ts | 2 +- tests/index.test.ts | 7 +- tests/subtitles.test.ts | 11 +++- tests/token-service-refresh.test.ts | 1 - tests/token-service.test.ts | 1 - tests/youtube-caption-tracks.test.ts | 78 ++++++++++++++++++++++ 11 files changed, 202 insertions(+), 86 deletions(-) create mode 100644 src/youtube-caption-tracks.ts delete mode 100644 src/youtube-client-profile.ts create mode 100644 tests/youtube-caption-tracks.test.ts diff --git a/src/innertube.ts b/src/innertube.ts index edcf94f..c27ecf6 100644 --- a/src/innertube.ts +++ b/src/innertube.ts @@ -1,6 +1,5 @@ import type { IntegrityTokenData } from "bgutils-js"; import { WEB_CLIENT_VERSION } from "./botguard-challenge.ts"; -import { ANDROID_VR_USER_AGENT } from "./youtube-client-profile.ts"; import { youtubeFetch } from "./youtube-fetch.ts"; const INNERTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; @@ -11,9 +10,6 @@ const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36"; -const ANDROID_VR_CLIENT_VERSION = "1.65.10"; -const ANDROID_VR_CLIENT_NAME_ID = "28"; - const WAA_HEADERS: Record = { "content-type": "application/json+protobuf", "x-goog-api-key": WAA_API_KEY, @@ -54,69 +50,6 @@ export async function fetchVisitorData(): Promise { return visitorData; } -export type RawCaptionTrack = { - baseUrl?: string; - name?: { simpleText?: string; runs?: { text?: string }[] }; - languageCode?: string; - kind?: string; - vssId?: string; -}; - -type PlayerResponse = { - captions?: { - playerCaptionsTracklistRenderer?: { - captionTracks?: RawCaptionTrack[]; - }; - }; -}; - -export async function fetchCaptionTracks( - videoId: string, - visitorData: string, - poToken: string, -): Promise { - const response = await youtubeFetch( - `https://www.youtube.com/youtubei/v1/player?key=${INNERTUBE_API_KEY}`, - { - method: "POST", - headers: { - "content-type": "application/json", - "x-youtube-client-name": ANDROID_VR_CLIENT_NAME_ID, - "x-youtube-client-version": ANDROID_VR_CLIENT_VERSION, - "x-goog-visitor-id": visitorData, - origin: "https://www.youtube.com", - "user-agent": ANDROID_VR_USER_AGENT, - }, - body: JSON.stringify({ - context: { - client: { - hl: "en", - gl: "US", - clientName: "ANDROID_VR", - clientVersion: ANDROID_VR_CLIENT_VERSION, - androidSdkVersion: 32, - deviceMake: "Oculus", - deviceModel: "Quest 3", - userAgent: ANDROID_VR_USER_AGENT, - osName: "Android", - osVersion: "12L", - visitorData, - }, - }, - videoId, - serviceIntegrityDimensions: { poToken }, - }), - }, - ); - - if (!response.ok) { - throw new Error(`Innertube player request failed: ${response.status}`); - } - - const data = (await response.json()) as PlayerResponse; - return data.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? []; -} - export async function fetchIntegrityToken(botguardResponse: string): Promise { const response = await youtubeFetch(`${WAA_BASE_URL}/GenerateIT`, { method: "POST", diff --git a/src/subtitle-content.ts b/src/subtitle-content.ts index e127d20..7197a56 100644 --- a/src/subtitle-content.ts +++ b/src/subtitle-content.ts @@ -1,7 +1,7 @@ -import { fetchCaptionTracks, type RawCaptionTrack } from "./innertube.ts"; import { fetchPoToken } from "./token-service.ts"; -import { ANDROID_VR_USER_AGENT } from "./youtube-client-profile.ts"; +import { fetchCaptionTracks, type RawCaptionTrack } from "./youtube-caption-tracks.ts"; import { youtubeFetch } from "./youtube-fetch.ts"; +import { MWEB_USER_AGENT } from "./youtube-mweb-config.ts"; export type SubtitleErrorCode = | "subtitle_request_invalid" @@ -32,8 +32,8 @@ export async function fetchSubtitleContent( fetchYoutube: typeof youtubeFetch = youtubeFetch, ): Promise { const request = parseSubtitleRequest(rawUrl); - const { visitorData, streamingPot } = await fetchPoToken(request.videoId); - const tracks = await fetchCaptionTracks(request.videoId, visitorData, streamingPot); + const { visitorData, poToken } = await fetchPoToken(request.videoId); + const tracks = await fetchCaptionTracks(request.videoId, visitorData, poToken); const track = selectTrack(tracks, request); if (!track?.baseUrl) { throw new SubtitleFetchError("Subtitle track not found", "subtitle_track_not_found", 404); @@ -43,8 +43,9 @@ export async function fetchSubtitleContent( { headers: { accept: "text/vtt,*/*;q=0.8", - origin: "https://www.youtube.com", - "user-agent": ANDROID_VR_USER_AGENT, + origin: "https://m.youtube.com", + referer: "https://m.youtube.com/", + "user-agent": MWEB_USER_AGENT, "x-goog-visitor-id": visitorData, }, }, @@ -118,7 +119,7 @@ function selectTrack( } function refreshedVttUrl(baseUrl: string, translatedLanguageTag: string | null): URL { - const url = new URL(baseUrl, "https://www.youtube.com"); + const url = new URL(baseUrl, "https://m.youtube.com"); if ( url.protocol !== "https:" || !isYoutubeHost(url.hostname) || diff --git a/src/subtitles.ts b/src/subtitles.ts index f78ae66..ec26579 100644 --- a/src/subtitles.ts +++ b/src/subtitles.ts @@ -1,5 +1,5 @@ -import { fetchCaptionTracks, type RawCaptionTrack } from "./innertube.ts"; import { fetchPoToken } from "./token-service.ts"; +import { fetchCaptionTracks, type RawCaptionTrack } from "./youtube-caption-tracks.ts"; export type SubtitleTrack = { url: string; @@ -24,8 +24,8 @@ function toSubtitleTrack(raw: RawCaptionTrack): SubtitleTrack | null { } export async function fetchSubtitles(videoId: string): Promise { - const { visitorData, streamingPot } = await fetchPoToken(videoId); - const raw = await fetchCaptionTracks(videoId, visitorData, streamingPot); + const { visitorData, poToken } = await fetchPoToken(videoId); + const raw = await fetchCaptionTracks(videoId, visitorData, poToken); return raw.flatMap((t) => { const track = toSubtitleTrack(t); return track ? [track] : []; diff --git a/src/youtube-caption-tracks.ts b/src/youtube-caption-tracks.ts new file mode 100644 index 0000000..9170617 --- /dev/null +++ b/src/youtube-caption-tracks.ts @@ -0,0 +1,97 @@ +import { YTNodes } from "youtubei.js"; +import { + getYoutubeInnertube, + invalidateYoutubeInnertube, + isRejectedAnonymousSession, + type YoutubeInnertube, +} from "./youtube-innertube-session.ts"; +import { buildYoutubeSabrPlayerRequest } from "./youtube-sabr-player-request.ts"; + +export type RawCaptionTrack = { + baseUrl?: string; + name?: { simpleText?: string; runs?: { text?: string }[] }; + languageCode?: string; + kind?: string; + vssId?: string; +}; + +type CaptionTrackData = { + base_url: string; + name: { toString(): string }; + language_code: string; + kind?: string; + vss_id: string; +}; + +type CaptionPlayerResponse = { + playability_status?: { status?: string; reason?: string }; + captions?: { caption_tracks?: CaptionTrackData[] }; +}; + +type CaptionTrackDependencies = { + getInnertube(client: "MWEB", visitorData: string): Promise; + invalidateInnertube( + client: "MWEB", + visitorData: string, + innertube: YoutubeInnertube, + ): Promise; + fetchPlayer( + videoId: string, + innertube: YoutubeInnertube, + playerPoToken: string, + ): Promise; +}; + +const dependencies: CaptionTrackDependencies = { + getInnertube: getYoutubeInnertube, + invalidateInnertube: invalidateYoutubeInnertube, + fetchPlayer: fetchCaptionPlayer, +}; + +export async function fetchCaptionTracks( + videoId: string, + visitorData: string, + playerPoToken: string, + deps: CaptionTrackDependencies = dependencies, +): Promise { + let innertube = await deps.getInnertube("MWEB", visitorData); + let response = await deps.fetchPlayer(videoId, innertube, playerPoToken); + if ( + isRejectedAnonymousSession( + response.playability_status?.status, + response.playability_status?.reason, + ) + ) { + await deps.invalidateInnertube("MWEB", visitorData, innertube); + innertube = await deps.getInnertube("MWEB", visitorData); + response = await deps.fetchPlayer(videoId, innertube, playerPoToken); + } + if (response.playability_status?.status !== "OK") { + throw new Error( + `YouTube MWEB player response is ${response.playability_status?.status ?? "missing"}: ${response.playability_status?.reason ?? "no reason"}`, + ); + } + return (response.captions?.caption_tracks ?? []).map(toRawCaptionTrack); +} + +async function fetchCaptionPlayer( + videoId: string, + innertube: YoutubeInnertube, + playerPoToken: string, +): Promise { + const endpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } }); + return endpoint.call(innertube.actions, { + ...buildYoutubeSabrPlayerRequest(innertube.session.player?.signature_timestamp, playerPoToken), + parse: true, + }); +} + +function toRawCaptionTrack(track: CaptionTrackData): RawCaptionTrack { + return { + baseUrl: new URL(track.base_url, "https://m.youtube.com").toString(), + name: { simpleText: track.name.toString() }, + languageCode: track.language_code, + kind: track.kind, + vssId: track.vss_id, + }; +} diff --git a/src/youtube-client-profile.ts b/src/youtube-client-profile.ts deleted file mode 100644 index 83f8d72..0000000 --- a/src/youtube-client-profile.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const ANDROID_VR_USER_AGENT = - "com.google.android.apps.youtube.vr.oculus/1.65.10 " + - "(Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip"; diff --git a/src/youtube-mweb-config.ts b/src/youtube-mweb-config.ts index 0232e9e..ce8f26d 100644 --- a/src/youtube-mweb-config.ts +++ b/src/youtube-mweb-config.ts @@ -4,7 +4,7 @@ export type YoutubeMwebConfig = { const MWEB_CONFIG_URL = "https://m.youtube.com/sw.js_data"; const MWEB_REFERER = "https://m.youtube.com/sw.js"; -const MWEB_USER_AGENT = +export const MWEB_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36"; export async function fetchYoutubeMwebConfig( diff --git a/tests/index.test.ts b/tests/index.test.ts index 3b7e88a..1e18a04 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, mock } from "bun:test"; -import type { RawCaptionTrack } from "../src/innertube.ts"; import type { SubtitleTrack } from "../src/subtitles.ts"; import type { TokenResult } from "../src/token-service.ts"; +import type { RawCaptionTrack } from "../src/youtube-caption-tracks.ts"; import type { YoutubePlayerDecodeResponse } from "../src/youtube-player-decoder.ts"; import type { YoutubeSabrSession } from "../src/youtube-sabr-session.ts"; @@ -55,12 +55,15 @@ mock.module("../src/token-service.ts", () => ({ })); mock.module("../src/innertube.ts", () => ({ - fetchCaptionTracks: mock(async (_videoId: string): Promise => []), fetchVisitorData: mock(async (): Promise => "visitor-data"), fetchChallenge: mock(async () => ({})), fetchIntegrityToken: mock(async () => ({ integrityToken: "integrity-token" })), })); +mock.module("../src/youtube-caption-tracks.ts", () => ({ + fetchCaptionTracks: mock(async (_videoId: string): Promise => []), +})); + mock.module("../src/youtube-sabr-session.ts", () => ({ fetchYoutubeSabrSession: mockFetchYoutubeSabrSession, })); diff --git a/tests/subtitles.test.ts b/tests/subtitles.test.ts index a94cc98..8c77a05 100644 --- a/tests/subtitles.test.ts +++ b/tests/subtitles.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, mock } from "bun:test"; import type { SubtitleTrack } from "../src/subtitles.ts"; +import { MWEB_USER_AGENT } from "../src/youtube-mweb-config.ts"; const VISITOR_DATA = "visitor-data-test-123"; const INTEGRITY_TOKEN = "integrity-token-test-xyz"; @@ -52,8 +53,11 @@ const mockFetchCaptionTracks = mock( async (_videoId: string, _visitorData: string, _poToken: string) => [frTrack, asrTrack], ); -mock.module("../src/innertube.ts", () => ({ +mock.module("../src/youtube-caption-tracks.ts", () => ({ fetchCaptionTracks: mockFetchCaptionTracks, +})); + +mock.module("../src/innertube.ts", () => ({ fetchVisitorData: mock(async (): Promise => VISITOR_DATA), fetchChallenge: mock(async () => ({})), fetchIntegrityToken: mock(async () => ({ integrityToken: INTEGRITY_TOKEN })), @@ -81,6 +85,7 @@ describe("fetchSubtitles", () => { displayLanguageName: "English (auto-generated)", isAutoGenerated: true, }); + expect(mockFetchCaptionTracks).toHaveBeenLastCalledWith("dQw4w9WgXcQ", VISITOR_DATA, PO_TOKEN); }); it("returns empty array when no caption tracks", async () => { @@ -118,11 +123,15 @@ describe("fetchSubtitles", () => { mockYoutubeFetch, ); const requested = new URL(String(mockYoutubeFetch.mock.calls.at(-1)?.[0])); + const requestHeaders = new Headers(mockYoutubeFetch.mock.calls.at(-1)?.[1]?.headers); expect(new TextDecoder().decode(bytes).startsWith("WEBVTT")).toBe(true); expect(requested.searchParams.get("expire")).toBe("999"); expect(requested.searchParams.get("fmt")).toBe("vtt"); expect(requested.searchParams.get("tlang")).toBe("es"); + expect(requestHeaders.get("origin")).toBe("https://m.youtube.com"); + expect(requestHeaders.get("referer")).toBe("https://m.youtube.com/"); + expect(requestHeaders.get("user-agent")).toBe(MWEB_USER_AGENT); }); it("selects the refreshed automatic track", async () => { diff --git a/tests/token-service-refresh.test.ts b/tests/token-service-refresh.test.ts index 5fe426a..42fc485 100644 --- a/tests/token-service-refresh.test.ts +++ b/tests/token-service-refresh.test.ts @@ -60,7 +60,6 @@ mock.module("../src/botguard-challenge.ts", () => ({ })); mock.module("../src/innertube.ts", () => ({ - fetchCaptionTracks: mock(async () => []), fetchVisitorData: mock(async (): Promise => { visitorDataRequests += 1; if (visitorDataRequests === 1) { diff --git a/tests/token-service.test.ts b/tests/token-service.test.ts index 935ee29..b912b1a 100644 --- a/tests/token-service.test.ts +++ b/tests/token-service.test.ts @@ -35,7 +35,6 @@ mock.module("../src/innertube.ts", () => ({ estimatedTtlSecs: 21600, }), ), - fetchCaptionTracks: mock(async (_videoId: string, _visitorData: string, _poToken: string) => []), })); let fetchPoToken: ( diff --git a/tests/youtube-caption-tracks.test.ts b/tests/youtube-caption-tracks.test.ts new file mode 100644 index 0000000..b74dece --- /dev/null +++ b/tests/youtube-caption-tracks.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { YoutubeInnertube } from "../src/youtube-innertube-session.ts"; + +const firstSession = { id: "first" } as unknown as YoutubeInnertube; +const secondSession = { id: "second" } as unknown as YoutubeInnertube; + +describe("YouTube caption tracks", () => { + it("loads and normalizes tracks through the MWEB player contract", async () => { + const { fetchCaptionTracks } = await import( + "../src/youtube-caption-tracks.ts?caption-contract" + ); + const getInnertube = mock(async () => firstSession); + const invalidateInnertube = mock(async () => undefined); + const fetchPlayer = mock(async () => ({ + playability_status: { status: "OK" }, + captions: { + caption_tracks: [ + { + base_url: "/api/timedtext?v=video&lang=en", + name: { toString: () => "English" }, + language_code: "en", + vss_id: ".en", + is_translatable: true, + }, + ], + }, + })); + + const tracks = await fetchCaptionTracks("video", "visitor", "visitor-pot", { + getInnertube, + invalidateInnertube, + fetchPlayer, + }); + + expect(getInnertube).toHaveBeenCalledWith("MWEB", "visitor"); + expect(fetchPlayer).toHaveBeenCalledWith("video", firstSession, "visitor-pot"); + expect(invalidateInnertube).not.toHaveBeenCalled(); + expect(tracks).toEqual([ + { + baseUrl: "https://m.youtube.com/api/timedtext?v=video&lang=en", + name: { simpleText: "English" }, + languageCode: "en", + kind: undefined, + vssId: ".en", + }, + ]); + }); + + it("recreates a rejected anonymous MWEB session only once", async () => { + const { fetchCaptionTracks } = await import( + "../src/youtube-caption-tracks.ts?caption-contract" + ); + const getInnertube = mock(async () => + getInnertube.mock.calls.length === 1 ? firstSession : secondSession, + ); + const invalidateInnertube = mock(async () => undefined); + const fetchPlayer = mock(async () => + fetchPlayer.mock.calls.length === 1 + ? { + playability_status: { + status: "LOGIN_REQUIRED", + reason: "Sign in to confirm you are not a bot", + }, + } + : { playability_status: { status: "OK" } }, + ); + + await fetchCaptionTracks("video", "visitor", "visitor-pot", { + getInnertube, + invalidateInnertube, + fetchPlayer, + }); + + expect(getInnertube).toHaveBeenCalledTimes(2); + expect(invalidateInnertube).toHaveBeenCalledWith("MWEB", "visitor", firstSession); + expect(fetchPlayer).toHaveBeenCalledTimes(2); + }); +}); From 8ae3146567af05c0f4736b15242438fd3456a465 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 16:48:01 +0200 Subject: [PATCH 07/10] fix: prefer WEB subtitle tracks --- src/youtube-caption-tracks.ts | 40 ++++++++++++++----- tests/youtube-caption-tracks.test.ts | 59 +++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/youtube-caption-tracks.ts b/src/youtube-caption-tracks.ts index 9170617..17f5ec1 100644 --- a/src/youtube-caption-tracks.ts +++ b/src/youtube-caption-tracks.ts @@ -6,6 +6,7 @@ import { type YoutubeInnertube, } from "./youtube-innertube-session.ts"; import { buildYoutubeSabrPlayerRequest } from "./youtube-sabr-player-request.ts"; +import type { YoutubeSabrClient } from "./youtube-sabr-types.ts"; export type RawCaptionTrack = { baseUrl?: string; @@ -29,9 +30,9 @@ type CaptionPlayerResponse = { }; type CaptionTrackDependencies = { - getInnertube(client: "MWEB", visitorData: string): Promise; + getInnertube(client: YoutubeSabrClient, visitorData: string): Promise; invalidateInnertube( - client: "MWEB", + client: YoutubeSabrClient, visitorData: string, innertube: YoutubeInnertube, ): Promise; @@ -54,7 +55,25 @@ export async function fetchCaptionTracks( playerPoToken: string, deps: CaptionTrackDependencies = dependencies, ): Promise { - let innertube = await deps.getInnertube("MWEB", visitorData); + const webTracks = await fetchCaptionTracksForClient( + "WEB", + videoId, + visitorData, + playerPoToken, + deps, + ).catch(() => []); + if (webTracks.length > 0) return webTracks; + return fetchCaptionTracksForClient("MWEB", videoId, visitorData, playerPoToken, deps); +} + +async function fetchCaptionTracksForClient( + client: YoutubeSabrClient, + videoId: string, + visitorData: string, + playerPoToken: string, + deps: CaptionTrackDependencies, +): Promise { + let innertube = await deps.getInnertube(client, visitorData); let response = await deps.fetchPlayer(videoId, innertube, playerPoToken); if ( isRejectedAnonymousSession( @@ -62,16 +81,16 @@ export async function fetchCaptionTracks( response.playability_status?.reason, ) ) { - await deps.invalidateInnertube("MWEB", visitorData, innertube); - innertube = await deps.getInnertube("MWEB", visitorData); + await deps.invalidateInnertube(client, visitorData, innertube); + innertube = await deps.getInnertube(client, visitorData); response = await deps.fetchPlayer(videoId, innertube, playerPoToken); } if (response.playability_status?.status !== "OK") { throw new Error( - `YouTube MWEB player response is ${response.playability_status?.status ?? "missing"}: ${response.playability_status?.reason ?? "no reason"}`, + `YouTube ${client} player response is ${response.playability_status?.status ?? "missing"}: ${response.playability_status?.reason ?? "no reason"}`, ); } - return (response.captions?.caption_tracks ?? []).map(toRawCaptionTrack); + return (response.captions?.caption_tracks ?? []).map((track) => toRawCaptionTrack(track, client)); } async function fetchCaptionPlayer( @@ -86,9 +105,12 @@ async function fetchCaptionPlayer( }); } -function toRawCaptionTrack(track: CaptionTrackData): RawCaptionTrack { +function toRawCaptionTrack(track: CaptionTrackData, client: YoutubeSabrClient): RawCaptionTrack { return { - baseUrl: new URL(track.base_url, "https://m.youtube.com").toString(), + baseUrl: new URL( + track.base_url, + client === "WEB" ? "https://www.youtube.com" : "https://m.youtube.com", + ).toString(), name: { simpleText: track.name.toString() }, languageCode: track.language_code, kind: track.kind, diff --git a/tests/youtube-caption-tracks.test.ts b/tests/youtube-caption-tracks.test.ts index b74dece..394e860 100644 --- a/tests/youtube-caption-tracks.test.ts +++ b/tests/youtube-caption-tracks.test.ts @@ -5,7 +5,7 @@ const firstSession = { id: "first" } as unknown as YoutubeInnertube; const secondSession = { id: "second" } as unknown as YoutubeInnertube; describe("YouTube caption tracks", () => { - it("loads and normalizes tracks through the MWEB player contract", async () => { + it("loads and normalizes tracks through the WEB player contract", async () => { const { fetchCaptionTracks } = await import( "../src/youtube-caption-tracks.ts?caption-contract" ); @@ -32,12 +32,12 @@ describe("YouTube caption tracks", () => { fetchPlayer, }); - expect(getInnertube).toHaveBeenCalledWith("MWEB", "visitor"); + expect(getInnertube).toHaveBeenCalledWith("WEB", "visitor"); expect(fetchPlayer).toHaveBeenCalledWith("video", firstSession, "visitor-pot"); expect(invalidateInnertube).not.toHaveBeenCalled(); expect(tracks).toEqual([ { - baseUrl: "https://m.youtube.com/api/timedtext?v=video&lang=en", + baseUrl: "https://www.youtube.com/api/timedtext?v=video&lang=en", name: { simpleText: "English" }, languageCode: "en", kind: undefined, @@ -46,7 +46,42 @@ describe("YouTube caption tracks", () => { ]); }); - it("recreates a rejected anonymous MWEB session only once", async () => { + it("falls back to MWEB when WEB does not return caption tracks", async () => { + const { fetchCaptionTracks } = await import( + "../src/youtube-caption-tracks.ts?caption-fallback" + ); + const getInnertube = mock(async (client: "WEB" | "MWEB") => + client === "WEB" ? firstSession : secondSession, + ); + const invalidateInnertube = mock(async () => undefined); + const fetchPlayer = mock(async (_videoId: string, session: YoutubeInnertube) => ({ + playability_status: { status: "OK" }, + captions: + session === secondSession + ? { + caption_tracks: [ + { + base_url: "/api/timedtext?v=video&lang=en", + name: { toString: () => "English" }, + language_code: "en", + vss_id: ".en", + }, + ], + } + : undefined, + })); + + const tracks = await fetchCaptionTracks("video", "visitor", "visitor-pot", { + getInnertube, + invalidateInnertube, + fetchPlayer, + }); + + expect(getInnertube.mock.calls.map(([client]) => client)).toEqual(["WEB", "MWEB"]); + expect(tracks[0]?.baseUrl).toBe("https://m.youtube.com/api/timedtext?v=video&lang=en"); + }); + + it("recreates a rejected anonymous WEB session only once", async () => { const { fetchCaptionTracks } = await import( "../src/youtube-caption-tracks.ts?caption-contract" ); @@ -62,7 +97,19 @@ describe("YouTube caption tracks", () => { reason: "Sign in to confirm you are not a bot", }, } - : { playability_status: { status: "OK" } }, + : { + playability_status: { status: "OK" }, + captions: { + caption_tracks: [ + { + base_url: "/api/timedtext?v=video&lang=en", + name: { toString: () => "English" }, + language_code: "en", + vss_id: ".en", + }, + ], + }, + }, ); await fetchCaptionTracks("video", "visitor", "visitor-pot", { @@ -72,7 +119,7 @@ describe("YouTube caption tracks", () => { }); expect(getInnertube).toHaveBeenCalledTimes(2); - expect(invalidateInnertube).toHaveBeenCalledWith("MWEB", "visitor", firstSession); + expect(invalidateInnertube).toHaveBeenCalledWith("WEB", "visitor", firstSession); expect(fetchPlayer).toHaveBeenCalledTimes(2); }); }); From b3c247b52dcef07c649d79e4fd272f99dfba01d4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 17:00:41 +0200 Subject: [PATCH 08/10] fix: preserve fresh WEB subtitle tracks --- src/subtitle-content.ts | 40 ++++++++++++++++++++++++++++++---------- tests/subtitles.test.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/subtitle-content.ts b/src/subtitle-content.ts index 7197a56..8d090ce 100644 --- a/src/subtitle-content.ts +++ b/src/subtitle-content.ts @@ -32,24 +32,44 @@ export async function fetchSubtitleContent( fetchYoutube: typeof youtubeFetch = youtubeFetch, ): Promise { const request = parseSubtitleRequest(rawUrl); + try { + return await fetchVttContent( + refreshedVttUrl(rawUrl, request.translatedLanguageTag), + fetchYoutube, + ); + } catch (error) { + if (error instanceof SubtitleFetchError && error.code === "subtitle_upstream_throttled") { + throw error; + } + } + const { visitorData, poToken } = await fetchPoToken(request.videoId); const tracks = await fetchCaptionTracks(request.videoId, visitorData, poToken); const track = selectTrack(tracks, request); if (!track?.baseUrl) { throw new SubtitleFetchError("Subtitle track not found", "subtitle_track_not_found", 404); } - const response = await fetchYoutube( + return fetchVttContent( refreshedVttUrl(track.baseUrl, request.translatedLanguageTag), - { - headers: { - accept: "text/vtt,*/*;q=0.8", - origin: "https://m.youtube.com", - referer: "https://m.youtube.com/", - "user-agent": MWEB_USER_AGENT, - "x-goog-visitor-id": visitorData, - }, - }, + fetchYoutube, + visitorData, ); +} + +async function fetchVttContent( + url: URL, + fetchYoutube: typeof youtubeFetch, + visitorData?: string, +): Promise { + const response = await fetchYoutube(url, { + headers: { + accept: "text/vtt,*/*;q=0.8", + origin: "https://m.youtube.com", + referer: "https://m.youtube.com/", + "user-agent": MWEB_USER_AGENT, + ...(visitorData ? { "x-goog-visitor-id": visitorData } : {}), + }, + }); if (response.status === 429) { throw new SubtitleFetchError( "YouTube temporarily throttled subtitle retrieval", diff --git a/tests/subtitles.test.ts b/tests/subtitles.test.ts index 8c77a05..92adb1b 100644 --- a/tests/subtitles.test.ts +++ b/tests/subtitles.test.ts @@ -114,7 +114,8 @@ describe("fetchSubtitles", () => { vssId: ".fr", }, ]); - mockYoutubeFetch.mockImplementation( + mockYoutubeFetch.mockImplementationOnce(async () => new Response("", { status: 403 })); + mockYoutubeFetch.mockImplementationOnce( async () => new Response("WEBVTT\n\n00:00.000 --> 00:01.000\nBonjour"), ); const { fetchSubtitleContent } = await import("../src/subtitle-content.ts"); @@ -126,6 +127,7 @@ describe("fetchSubtitles", () => { const requestHeaders = new Headers(mockYoutubeFetch.mock.calls.at(-1)?.[1]?.headers); expect(new TextDecoder().decode(bytes).startsWith("WEBVTT")).toBe(true); + expect(mockYoutubeFetch).toHaveBeenCalledTimes(2); expect(requested.searchParams.get("expire")).toBe("999"); expect(requested.searchParams.get("fmt")).toBe("vtt"); expect(requested.searchParams.get("tlang")).toBe("es"); @@ -139,7 +141,8 @@ describe("fetchSubtitles", () => { { ...frTrack, languageCode: "en", vssId: ".en" }, asrTrack, ]); - mockYoutubeFetch.mockImplementation(async () => new Response("WEBVTT\n\nAutomatic")); + mockYoutubeFetch.mockImplementationOnce(async () => new Response("", { status: 403 })); + mockYoutubeFetch.mockImplementationOnce(async () => new Response("WEBVTT\n\nAutomatic")); const { fetchSubtitleContent } = await import("../src/subtitle-content.ts"); await fetchSubtitleContent( "https://www.youtube.com/api/timedtext?v=video&lang=en&kind=asr&expire=1", @@ -151,6 +154,26 @@ describe("fetchSubtitles", () => { expect(requested.searchParams.get("expire")).toBe("999"); }); + it("uses a current WEB track without replacing it through MWEB", async () => { + mockFetchCaptionTracks.mockClear(); + mockYoutubeFetch.mockClear(); + mockYoutubeFetch.mockImplementationOnce( + async () => new Response("WEBVTT\n\n00:00.000 --> 00:01.000\nCurrent"), + ); + const { fetchSubtitleContent } = await import("../src/subtitle-content.ts"); + const bytes = await fetchSubtitleContent( + "https://www.youtube.com/api/timedtext?v=video&lang=en&expire=999&fmt=ttml", + mockYoutubeFetch, + ); + const requested = new URL(String(mockYoutubeFetch.mock.calls[0]?.[0])); + + expect(new TextDecoder().decode(bytes).startsWith("WEBVTT")).toBe(true); + expect(requested.hostname).toBe("www.youtube.com"); + expect(requested.searchParams.get("fmt")).toBe("vtt"); + expect(mockFetchCaptionTracks).not.toHaveBeenCalled(); + expect(mockYoutubeFetch).toHaveBeenCalledTimes(1); + }); + it("returns a typed throttling failure without retrying", async () => { mockFetchCaptionTracks.mockImplementation(async () => [asrTrack]); mockYoutubeFetch.mockClear(); From c4476ba1a4b9fc82219ee65455b9b6d8ba17a74b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 20:49:09 +0200 Subject: [PATCH 09/10] fix: keep YouTube requests on configured proxy --- src/youtube-fetch.ts | 6 +----- tests/youtube-fetch.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 tests/youtube-fetch.test.ts diff --git a/src/youtube-fetch.ts b/src/youtube-fetch.ts index ee799da..d037026 100644 --- a/src/youtube-fetch.ts +++ b/src/youtube-fetch.ts @@ -6,9 +6,5 @@ export async function youtubeFetch( ): Promise { if (proxyUrl === null) return fetch(input, init); const proxyInit: BunFetchRequestInit = { ...init, proxy: proxyUrl }; - try { - return await fetch(input, proxyInit); - } catch { - return fetch(input, init); - } + return fetch(input, proxyInit); } diff --git a/tests/youtube-fetch.test.ts b/tests/youtube-fetch.test.ts new file mode 100644 index 0000000..cee0ac7 --- /dev/null +++ b/tests/youtube-fetch.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +const originalFetch = globalThis.fetch; +const originalProxyUrl = process.env.YOUTUBE_OUTBOUND_PROXY_URL; + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalProxyUrl === undefined) { + delete process.env.YOUTUBE_OUTBOUND_PROXY_URL; + } else { + process.env.YOUTUBE_OUTBOUND_PROXY_URL = originalProxyUrl; + } +}); + +describe("youtubeFetch", () => { + it("does not bypass a configured proxy after a transport failure", async () => { + process.env.YOUTUBE_OUTBOUND_PROXY_URL = "http://proxy.internal:8080"; + const fetcher = mock(async (_input: RequestInfo | URL, init?: BunFetchRequestInit) => { + if (init?.proxy !== undefined) throw new Error("proxy unavailable"); + return new Response("direct response"); + }); + globalThis.fetch = fetcher as typeof fetch; + const { youtubeFetch } = await import("../src/youtube-fetch.ts?fail-closed-test"); + + await expect(youtubeFetch("https://www.youtube.com")).rejects.toThrow("proxy unavailable"); + expect(fetcher).toHaveBeenCalledTimes(1); + const init = fetcher.mock.calls[0]?.[1] as BunFetchRequestInit | undefined; + expect(init?.proxy).toBe("http://proxy.internal:8080"); + }); +}); From 0f7df07f3a571b39fb55445b585cb5b2e623d643 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 6 Aug 2026 10:58:29 +0200 Subject: [PATCH 10/10] chore: prepare token 1.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b92fac8..818cdc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.3.1", + "version": "1.4.0", "private": true, "license": "MIT", "scripts": {