Skip to content
Merged
5 changes: 5 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-]/-}"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "typetype-token",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"license": "MIT",
"scripts": {
Expand Down
20 changes: 3 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand All @@ -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";
Expand Down
68 changes: 0 additions & 68 deletions src/innertube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"content-type": "application/json+protobuf",
"x-goog-api-key": WAA_API_KEY,
Expand Down Expand Up @@ -56,68 +50,6 @@ export async function fetchVisitorData(): Promise<string> {
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<RawCaptionTrack[]> {
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<IntegrityTokenData> {
const response = await youtubeFetch(`${WAA_BASE_URL}/GenerateIT`, {
method: "POST",
Expand Down
175 changes: 175 additions & 0 deletions src/subtitle-content.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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<Uint8Array> {
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;
49 changes: 49 additions & 0 deletions src/subtitle-routes.ts
Original file line number Diff line number Diff line change
@@ -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<Response | null> {
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<Response> {
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<Response> {
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 },
);
}
}
7 changes: 3 additions & 4 deletions src/subtitles.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,8 +24,8 @@ function toSubtitleTrack(raw: RawCaptionTrack): SubtitleTrack | null {
}

export async function fetchSubtitles(videoId: string): Promise<SubtitleTrack[]> {
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] : [];
Expand Down
Loading