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-]/-}" diff --git a/package.json b/package.json index 95152fd..818cdc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.3.0", + "version": "1.4.0", "private": true, "license": "MIT", "scripts": { 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..c27ecf6 100644 --- a/src/innertube.ts +++ b/src/innertube.ts @@ -10,12 +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_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"; - const WAA_HEADERS: Record = { "content-type": "application/json+protobuf", "x-goog-api-key": WAA_API_KEY, @@ -56,68 +50,6 @@ export async function fetchVisitorData(): Promise { return visitorData; } -export type RawCaptionTrack = { - baseUrl?: string; - name?: { simpleText?: string; runs?: { text?: string }[] }; - languageCode?: string; - kind?: 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 new file mode 100644 index 0000000..8d090ce --- /dev/null +++ b/src/subtitle-content.ts @@ -0,0 +1,175 @@ +import { fetchPoToken } from "./token-service.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" + | "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); + 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); + } + return fetchVttContent( + refreshedVttUrl(track.baseUrl, request.translatedLanguageTag), + 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", + "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://m.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..ec26579 100644 --- a/src/subtitles.ts +++ b/src/subtitles.ts @@ -1,6 +1,5 @@ -import type { RawCaptionTrack } from "./innertube.ts"; -import { fetchCaptionTracks } from "./innertube.ts"; import { fetchPoToken } from "./token-service.ts"; +import { fetchCaptionTracks, type RawCaptionTrack } from "./youtube-caption-tracks.ts"; export type SubtitleTrack = { url: string; @@ -25,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..17f5ec1 --- /dev/null +++ b/src/youtube-caption-tracks.ts @@ -0,0 +1,119 @@ +import { YTNodes } from "youtubei.js"; +import { + getYoutubeInnertube, + invalidateYoutubeInnertube, + isRejectedAnonymousSession, + 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; + 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: YoutubeSabrClient, visitorData: string): Promise; + invalidateInnertube( + client: YoutubeSabrClient, + 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 { + 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( + response.playability_status?.status, + response.playability_status?.reason, + ) + ) { + 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 ${client} player response is ${response.playability_status?.status ?? "missing"}: ${response.playability_status?.reason ?? "no reason"}`, + ); + } + return (response.captions?.caption_tracks ?? []).map((track) => toRawCaptionTrack(track, client)); +} + +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, client: YoutubeSabrClient): RawCaptionTrack { + return { + 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, + vssId: track.vss_id, + }; +} 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/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 ed51008..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, })); @@ -154,6 +157,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..92adb1b 100644 --- a/tests/subtitles.test.ts +++ b/tests/subtitles.test.ts @@ -1,9 +1,17 @@ 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"; 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,18 +42,22 @@ 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( 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 })), @@ -67,12 +79,13 @@ 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)", isAutoGenerated: true, }); + expect(mockFetchCaptionTracks).toHaveBeenLastCalledWith("dQw4w9WgXcQ", VISITOR_DATA, PO_TOKEN); }); it("returns empty array when no caption tracks", async () => { @@ -92,4 +105,104 @@ 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.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"); + 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])); + 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"); + 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 () => { + mockFetchCaptionTracks.mockImplementation(async () => [ + { ...frTrack, languageCode: "en", vssId: ".en" }, + asrTrack, + ]); + 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", + 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("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(); + 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(); + }); }); 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..394e860 --- /dev/null +++ b/tests/youtube-caption-tracks.test.ts @@ -0,0 +1,125 @@ +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 WEB 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("WEB", "visitor"); + expect(fetchPlayer).toHaveBeenCalledWith("video", firstSession, "visitor-pot"); + expect(invalidateInnertube).not.toHaveBeenCalled(); + expect(tracks).toEqual([ + { + baseUrl: "https://www.youtube.com/api/timedtext?v=video&lang=en", + name: { simpleText: "English" }, + languageCode: "en", + kind: undefined, + vssId: ".en", + }, + ]); + }); + + 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" + ); + 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" }, + 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", { + getInnertube, + invalidateInnertube, + fetchPlayer, + }); + + expect(getInnertube).toHaveBeenCalledTimes(2); + expect(invalidateInnertube).toHaveBeenCalledWith("WEB", "visitor", firstSession); + expect(fetchPlayer).toHaveBeenCalledTimes(2); + }); +}); 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"); + }); +});