Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
image: ${{ steps.build-info.outputs.image }}
labels: ${{ steps.meta.outputs.labels }}
metadata-json: ${{ steps.meta.outputs.json }}
platform-matrix: ${{ steps.build-info.outputs.platform-matrix }}
version: ${{ steps.build-info.outputs.version }}
steps:
- name: Checkout
Expand Down Expand Up @@ -85,6 +86,12 @@ jobs:
echo "version=$version"
echo "build-time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
} >> "$GITHUB_OUTPUT"
if [[ "$GITHUB_REF_NAME" == "dev" ]]; then
platform_matrix='{"include":[{"platform":"linux/amd64","arch":"amd64"}]}'
else
platform_matrix='{"include":[{"platform":"linux/amd64","arch":"amd64"},{"platform":"linux/arm64","arch":"arm64"}]}'
fi
echo "platform-matrix=$platform_matrix" >> "$GITHUB_OUTPUT"

- name: Extract metadata
id: meta
Expand All @@ -111,12 +118,7 @@ jobs:
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
- platform: linux/arm64
arch: arm64
matrix: ${{ fromJSON(needs.prepare.outputs.platform-matrix) }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
Expand Down Expand Up @@ -218,8 +220,10 @@ jobs:
METADATA_JSON: ${{ needs.prepare.outputs.metadata-json }}
run: |
mapfile -t digests < <(find "$DIGEST_DIR" -maxdepth 1 -type f -printf '%f\n' | sort)
if [[ "${#digests[@]}" -ne 2 ]]; then
echo "Expected two platform digests, found ${#digests[@]}"
expected_digests=2
if [[ "$GITHUB_REF_NAME" == "dev" ]]; then expected_digests=1; fi
if [[ "${#digests[@]}" -ne "$expected_digests" ]]; then
echo "Expected $expected_digests platform digests, found ${#digests[@]}"
exit 1
fi
mapfile -t tags < <(jq -r '.tags[]' <<< "$METADATA_JSON")
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<p>YouTube token, decoder, and session service for TypeType-Server.</p>
</div>

You want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md).

TypeType-Token is an internal Bun service used exclusively by [TypeType-Server](https://github.com/TypeType-Video/TypeType-Server). It handles YouTube Proof-of-Origin tokens, player decoding, SABR session metadata, subtitles, and disposable remote-login browser sessions.

The frontend never calls this service directly. If you want to run TypeType, use the [central stack](https://github.com/TypeType-Video/TypeType) instead of exposing this service as a public API.
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.8.1",
"version": "1.9.0",
"private": true,
"license": "MIT",
"scripts": {
Expand Down
33 changes: 23 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Server } from "bun";
import { buildInfo } from "./build-info.ts";
import { playbackTraceFromRequest, tracePlaybackRequest } from "./playback-diagnostics.ts";
import { handlePoTokenRequest } from "./po-token-routes.ts";
import { readRemoteLoginConfig } from "./remote-login-config.ts";
import { RemoteLoginManager } from "./remote-login-manager.ts";
Expand Down Expand Up @@ -42,13 +43,17 @@ export async function handler(
}

try {
const result = await fetchYoutubeSabrSession(
videoId,
clientParam as YoutubeSabrClient,
undefined,
url.searchParams.get("isolated") === "true",
);
return Response.json(result);
const trace = playbackTraceFromRequest(req);
return await tracePlaybackRequest(trace, "youtube.sabr.session", async () => {
const result = await fetchYoutubeSabrSession(
videoId,
clientParam as YoutubeSabrClient,
undefined,
url.searchParams.get("isolated") === "true",
trace ?? undefined,
);
return Response.json(result);
});
} catch (error) {
const message = error instanceof Error ? error.message : "Internal error";
return Response.json({ error: message }, { status: 500 });
Expand All @@ -75,9 +80,17 @@ export async function handler(
if (!reloadPlaybackParams || reloadPlaybackParams.length > 8192) {
return Response.json({ error: "reloadPlaybackParams is required" }, { status: 400 });
}
return Response.json(
await fetchYoutubeSabrSession(videoId, client as YoutubeSabrClient, reloadPlaybackParams),
);
const trace = playbackTraceFromRequest(req);
return await tracePlaybackRequest(trace, "youtube.sabr.session.reload", async () => {
const result = await fetchYoutubeSabrSession(
videoId,
client as YoutubeSabrClient,
reloadPlaybackParams,
false,
trace ?? undefined,
);
return Response.json(result);
});
} catch (error) {
const message = error instanceof Error ? error.message : "Internal error";
return Response.json({ error: message }, { status: 500 });
Expand Down
89 changes: 89 additions & 0 deletions src/playback-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
export type PlaybackTraceContext = Readonly<{
traceId: string;
requestId: string | null;
}>;

const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{8,128}$/;

export function playbackTraceFromRequest(req: Request): PlaybackTraceContext | null {
const traceId = req.headers.get("X-Playback-Trace-ID");
if (!traceId || !REQUEST_ID_PATTERN.test(traceId)) return null;
const requestId = req.headers.get("X-Request-ID");
return {
traceId,
requestId: requestId && REQUEST_ID_PATTERN.test(requestId) ? requestId : null,
};
}

export function playbackTraceEvent(
context: PlaybackTraceContext | null | undefined,
event: string,
fields: Record<string, string | number | boolean | null> = {},
): void {
if (!context) return;
console.info(
"[playback_trace] " +
JSON.stringify({
service: "token",
traceId: context.traceId,
requestId: context.requestId ?? "unknown",
event,
...fields,
}),
);
}

export async function tracePlaybackPhase<T>(
context: PlaybackTraceContext | null | undefined,
phase: string,
load: () => Promise<T>,
): Promise<T> {
if (!context) return load();
const startedAt = performance.now();
playbackTraceEvent(context, "phase.start", { phase });
try {
const result = await load();
playbackTraceEvent(context, "phase.end", {
phase,
outcome: "ok",
durationMs: Math.round(performance.now() - startedAt),
});
return result;
} catch (error) {
playbackTraceEvent(context, "phase.end", {
phase,
outcome: "error",
errorType: error instanceof Error ? error.name : "unknown",
durationMs: Math.round(performance.now() - startedAt),
});
throw error;
}
}

export async function tracePlaybackRequest(
context: PlaybackTraceContext | null,
operation: string,
load: () => Promise<Response>,
): Promise<Response> {
if (!context) return load();
const startedAt = performance.now();
playbackTraceEvent(context, "request.start", { operation });
try {
const response = await load();
playbackTraceEvent(context, "request.end", {
operation,
status: response.status,
outcome: response.ok ? "ok" : "http_error",
durationMs: Math.round(performance.now() - startedAt),
});
return response;
} catch (error) {
playbackTraceEvent(context, "request.end", {
operation,
outcome: "error",
errorType: error instanceof Error ? error.name : "unknown",
durationMs: Math.round(performance.now() - startedAt),
});
throw error;
}
}
55 changes: 34 additions & 21 deletions src/po-token-routes.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,46 @@
import { playbackTraceFromRequest, tracePlaybackRequest } from "./playback-diagnostics.ts";
import { fetchPoToken, fetchSessionPoTokens } from "./token-service.ts";

export async function handlePoTokenRequest(req: Request, url: URL): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/potoken") {
const videoId = url.searchParams.get("videoId");
if (!videoId) {
return Response.json({ error: "videoId query parameter is required" }, { status: 400 });
const trace = playbackTraceFromRequest(req);
return tracePlaybackRequest(trace, "potoken.video", async () => {
const videoId = url.searchParams.get("videoId");
if (!videoId) {
return Response.json({ error: "videoId query parameter is required" }, { status: 400 });
}
return tokenResponse(() =>
fetchPoToken(
videoId,
url.searchParams.get("refresh") === "true",
url.searchParams.get("refreshVideo") === "true",
trace ?? undefined,
),
);
});
}
if (req.method !== "POST" || url.pathname !== "/potoken/session") return null;
const trace = playbackTraceFromRequest(req);
return tracePlaybackRequest(trace, "potoken.session", async () => {
const body = (await req.json().catch(() => null)) as {
videoId?: unknown;
sessionBinding?: unknown;
refreshVideo?: unknown;
} | null;
const videoId = normalizedString(body?.videoId, 128);
const sessionBinding = normalizedString(body?.sessionBinding, 4096);
if (!videoId || !sessionBinding) {
return Response.json({ error: "videoId and sessionBinding are required" }, { status: 400 });
}
return tokenResponse(() =>
fetchPoToken(
fetchSessionPoTokens(
videoId,
url.searchParams.get("refresh") === "true",
url.searchParams.get("refreshVideo") === "true",
sessionBinding,
body?.refreshVideo === true,
trace ?? undefined,
),
);
}
if (req.method !== "POST" || url.pathname !== "/potoken/session") return null;
const body = (await req.json().catch(() => null)) as {
videoId?: unknown;
sessionBinding?: unknown;
refreshVideo?: unknown;
} | null;
const videoId = normalizedString(body?.videoId, 128);
const sessionBinding = normalizedString(body?.sessionBinding, 4096);
if (!videoId || !sessionBinding) {
return Response.json({ error: "videoId and sessionBinding are required" }, { status: 400 });
}
return tokenResponse(() =>
fetchSessionPoTokens(videoId, sessionBinding, body?.refreshVideo === true),
);
});
}

function normalizedString(value: unknown, maxLength: number): string | null {
Expand Down
54 changes: 50 additions & 4 deletions src/remote-login-browser.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Browser, Cookie } from "playwright";
import type { Browser, Cookie, Frame, Page } from "playwright";
import { chromium } from "playwright";
import type { RemoteLoginConfig } from "./remote-login-config.ts";
import { describeCookies, describeError, describeUrl } from "./remote-login-diagnostics.ts";

const LOGIN_URL = "https://www.youtube.com/signin";
const YOUTUBE_URL = "https://www.youtube.com";
const COOKIE_URLS = [
"https://www.youtube.com",
"https://accounts.google.com",
Expand Down Expand Up @@ -46,6 +48,9 @@ export type RemoteLoginPage = {
cookies: () => Promise<string>;
authUser: () => Promise<number>;
hasLoginCookie: () => Promise<boolean>;
url: () => string;
cookieSummary: () => Promise<string>;
observe: (listener: (event: string) => void) => void;
};

function launchArgs(config: RemoteLoginConfig): string[] {
Expand Down Expand Up @@ -88,8 +93,45 @@ function isAllowedCookie(cookie: Cookie): boolean {
return false;
}

function isLoginCookie(cookie: Cookie): boolean {
return isAllowedCookie(cookie) && LOGIN_COOKIE_NAMES.has(cookie.name) && cookie.value.length > 0;
export function isYoutubeLoginCookie(cookie: Cookie): boolean {
const domain = cookie.domain.toLowerCase();
const youtube = domain === "youtube.com" || domain.endsWith(".youtube.com");
return youtube && LOGIN_COOKIE_NAMES.has(cookie.name) && cookie.value.length > 0;
}

export function isYoutubeUrl(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return host === "youtube.com" || host.endsWith(".youtube.com");
} catch {
return false;
}
}

function observePage(page: Page, listener: (event: string) => void): void {
page.on("framenavigated", (frame: Frame) => {
if (frame === page.mainFrame()) listener(`navigated ${describeUrl(frame.url())}`);
});
page.on("response", (response) => {
const request = response.request();
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
listener(`document ${response.status()} ${describeUrl(response.url())}`);
}
});
page.on("requestfailed", (request) => {
if (request.isNavigationRequest()) {
listener(
`navigation failed ${describeUrl(request.url())} ${request.failure()?.errorText ?? ""}`,
);
}
});
page.on("dialog", (dialog) => {
listener(`dialog ${dialog.type()} dismissed`);
void dialog.dismiss().catch(() => undefined);
});
page.on("pageerror", (error) => listener(`page error ${describeError(error)}`));
page.on("crash", () => listener("page crashed"));
page.on("close", () => listener("page closed"));
}

function formatCookie(cookie: Cookie): string {
Expand Down Expand Up @@ -156,7 +198,11 @@ export async function createRemoteLoginPage(
const value = Number(ytcfg?.get("SESSION_INDEX") ?? 0);
return Number.isInteger(value) && value >= 0 && value <= 99 ? value : 0;
}),
hasLoginCookie: async () => (await context.cookies(COOKIE_URLS)).some(isLoginCookie),
hasLoginCookie: async () =>
isYoutubeUrl(page.url()) && (await context.cookies(YOUTUBE_URL)).some(isYoutubeLoginCookie),
url: () => page.url(),
cookieSummary: async () => describeCookies(await context.cookies(COOKIE_URLS)),
observe: (listener) => observePage(page, listener),
};
}

Expand Down
Loading