diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index e95be40..ee2c5aa 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -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
@@ -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
@@ -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
@@ -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")
diff --git a/README.md b/README.md
index 5b0dbe5..9dc8468 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,8 @@
YouTube token, decoder, and session service for TypeType-Server.
+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.
diff --git a/package.json b/package.json
index fb40520..e7194a3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "typetype-token",
- "version": "1.8.1",
+ "version": "1.9.0",
"private": true,
"license": "MIT",
"scripts": {
diff --git a/src/index.ts b/src/index.ts
index 839c5a9..18fab74 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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";
@@ -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 });
@@ -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 });
diff --git a/src/playback-diagnostics.ts b/src/playback-diagnostics.ts
new file mode 100644
index 0000000..c933d3a
--- /dev/null
+++ b/src/playback-diagnostics.ts
@@ -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 = {},
+): 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(
+ context: PlaybackTraceContext | null | undefined,
+ phase: string,
+ load: () => Promise,
+): Promise {
+ 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,
+): Promise {
+ 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;
+ }
+}
diff --git a/src/po-token-routes.ts b/src/po-token-routes.ts
index cd85601..7235a7b 100644
--- a/src/po-token-routes.ts
+++ b/src/po-token-routes.ts
@@ -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 {
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 {
diff --git a/src/remote-login-browser.ts b/src/remote-login-browser.ts
index ecadc18..eea45f3 100644
--- a/src/remote-login-browser.ts
+++ b/src/remote-login-browser.ts
@@ -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",
@@ -46,6 +48,9 @@ export type RemoteLoginPage = {
cookies: () => Promise;
authUser: () => Promise;
hasLoginCookie: () => Promise;
+ url: () => string;
+ cookieSummary: () => Promise;
+ observe: (listener: (event: string) => void) => void;
};
function launchArgs(config: RemoteLoginConfig): string[] {
@@ -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 {
@@ -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),
};
}
diff --git a/src/remote-login-diagnostics.ts b/src/remote-login-diagnostics.ts
new file mode 100644
index 0000000..65d6c27
--- /dev/null
+++ b/src/remote-login-diagnostics.ts
@@ -0,0 +1,63 @@
+import type { Cookie } from "playwright";
+import { logMessage } from "./remote-login-messages.ts";
+import type { RemoteLoginConnection } from "./remote-login-session-types.ts";
+
+const MAX_BUFFERED_LINES = 200;
+const MAX_MESSAGE_LENGTH = 400;
+const MAX_ERROR_LENGTH = 160;
+
+export class RemoteLoginDiagnostics {
+ private readonly startedAt = Date.now();
+ private readonly buffered: string[] = [];
+ private connection: RemoteLoginConnection | null = null;
+
+ constructor(private readonly label: string) {}
+
+ attach(connection: RemoteLoginConnection): void {
+ this.connection = connection;
+ for (const line of this.buffered) connection.sendText(line);
+ }
+
+ log(message: string): void {
+ const at = Date.now() - this.startedAt;
+ const text =
+ message.length > MAX_MESSAGE_LENGTH ? `${message.slice(0, MAX_MESSAGE_LENGTH)}...` : message;
+ console.log(`[remote-login ${this.label}] +${at}ms ${text}`);
+ const line = logMessage(at, text);
+ this.buffered.push(line);
+ if (this.buffered.length > MAX_BUFFERED_LINES) this.buffered.shift();
+ this.connection?.sendText(line);
+ }
+}
+
+export function describeUrl(url: string): string {
+ try {
+ const parsed = new URL(url);
+ return `${parsed.origin}${parsed.pathname}`;
+ } catch {
+ return "invalid-url";
+ }
+}
+
+export function describeCookies(cookies: Cookie[]): string {
+ const byDomain = new Map();
+ for (const cookie of cookies) {
+ const domain = cookie.domain.replace(/^\./, "");
+ const names = byDomain.get(domain) ?? [];
+ names.push(cookie.name);
+ byDomain.set(domain, names);
+ }
+ if (byDomain.size === 0) return "none";
+ return [...byDomain.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([domain, names]) => `${domain}=[${names.sort().join(",")}]`)
+ .join(" ");
+}
+
+export function describeError(error: unknown): string {
+ const message = error instanceof Error ? error.message : String(error);
+ const firstLine = message.split("\n", 1)[0] ?? "";
+ return firstLine.length > MAX_ERROR_LENGTH
+ ? `${firstLine.slice(0, MAX_ERROR_LENGTH)}...`
+ : firstLine;
+}
diff --git a/src/remote-login-input-queue.ts b/src/remote-login-input-queue.ts
index eaf0cf8..86edd21 100644
--- a/src/remote-login-input-queue.ts
+++ b/src/remote-login-input-queue.ts
@@ -1,4 +1,5 @@
import type { RemoteLoginPage } from "./remote-login-browser.ts";
+import { describeError } from "./remote-login-diagnostics.ts";
import { applyRemoteLoginInput } from "./remote-login-input.ts";
import type { RemoteLoginInput } from "./remote-login-messages.ts";
@@ -10,7 +11,10 @@ export class RemoteLoginInputQueue {
private draining = false;
private closed = false;
- constructor(private readonly fail: (message: string) => void) {}
+ constructor(
+ private readonly fail: (message: string) => void,
+ private readonly report: (message: string) => void = () => undefined,
+ ) {}
async attach(page: RemoteLoginPage["page"]): Promise {
if (this.closed) return;
@@ -37,6 +41,7 @@ export class RemoteLoginInputQueue {
} else {
if (this.pending.length >= MAX_INPUT_QUEUE) {
this.close();
+ this.report(`input queue exceeded ${MAX_INPUT_QUEUE} pending events`);
this.fail("Remote browser input queue exceeded");
return;
}
@@ -55,15 +60,21 @@ export class RemoteLoginInputQueue {
const page = this.page;
if (this.closed || this.draining || !page) return;
this.draining = true;
+ let current: RemoteLoginInput | undefined;
try {
while (!this.closed) {
- const message = this.pending.shift();
- if (!message) return;
- await applyRemoteLoginInput(page, message);
+ current = this.pending.shift();
+ if (!current) return;
+ await applyRemoteLoginInput(page, current);
+ if (current.type !== "pointer" || current.event !== "move")
+ this.report(describeInput(current));
}
- } catch {
+ } catch (error) {
if (!this.closed) {
this.close();
+ this.report(
+ `input ${current ? describeInput(current) : "unknown"} failed: ${describeError(error)}`,
+ );
this.fail("Remote browser input failed");
}
} finally {
@@ -71,3 +82,12 @@ export class RemoteLoginInputQueue {
}
}
}
+
+function describeInput(message: RemoteLoginInput): string {
+ if (message.type === "pointer") return `pointer ${message.event} ${message.x},${message.y}`;
+ if (message.type === "key") return `key ${message.event} ${message.key}`;
+ if (message.type === "text") return `text ${message.value.length} chars`;
+ if (message.type === "resize") return `resize ${message.width}x${message.height}`;
+ if (message.type === "wheel") return `wheel ${message.deltaX},${message.deltaY}`;
+ return message.type;
+}
diff --git a/src/remote-login-messages.ts b/src/remote-login-messages.ts
index 274e50a..7ec17a6 100644
--- a/src/remote-login-messages.ts
+++ b/src/remote-login-messages.ts
@@ -108,3 +108,7 @@ export function statusMessage(phase: RemoteLoginPhase): string {
export function errorMessage(message: string): string {
return JSON.stringify({ type: "error", message });
}
+
+export function logMessage(at: number, message: string): string {
+ return JSON.stringify({ type: "log", at, message });
+}
diff --git a/src/remote-login-session.ts b/src/remote-login-session.ts
index 9fd3276..4f0506f 100644
--- a/src/remote-login-session.ts
+++ b/src/remote-login-session.ts
@@ -1,6 +1,7 @@
import type { RemoteLoginPage } from "./remote-login-browser.ts";
import { sendRemoteLoginCompletion } from "./remote-login-callback.ts";
import type { RemoteLoginConfig } from "./remote-login-config.ts";
+import { describeError, describeUrl, RemoteLoginDiagnostics } from "./remote-login-diagnostics.ts";
import { RemoteLoginInputQueue } from "./remote-login-input-queue.ts";
import {
errorMessage,
@@ -15,6 +16,8 @@ import type {
RemoteLoginSessionOptions,
} from "./remote-login-session-types.ts";
+const SCREENSHOT_FAILURE_LOG_EVERY = 20;
+
export class RemoteLoginSession {
readonly sessionId: string;
readonly userId: string;
@@ -24,15 +27,21 @@ export class RemoteLoginSession {
private readonly onDone: (sessionId: string, userId: string) => void;
private readonly target: RemoteLoginSessionOptions["target"];
private readonly poToken = new RemoteLoginPoToken();
+ private readonly diagnostics: RemoteLoginDiagnostics;
private page: RemoteLoginPage | null = null;
private connection: RemoteLoginConnection | null = null;
private phase: RemoteLoginPhase = "opening";
private closed = false;
private captureStarted = false;
+ private lastLoginState = "";
+ private screenshotFailures = 0;
private expiryTimer: ReturnType;
private frameTimer: ReturnType | null = null;
private loginTimer: ReturnType | null = null;
- private readonly inputQueue = new RemoteLoginInputQueue((message) => this.fail(message));
+ private readonly inputQueue = new RemoteLoginInputQueue(
+ (message) => this.fail(message),
+ (message) => this.diagnostics.log(message),
+ );
constructor(options: RemoteLoginSessionOptions) {
this.sessionId = options.sessionId;
this.userId = options.userId;
@@ -41,36 +50,48 @@ export class RemoteLoginSession {
this.createPage = options.createPage;
this.onDone = options.onDone;
this.target = options.target;
+ this.diagnostics = new RemoteLoginDiagnostics(this.sessionId.slice(0, 8));
this.expiryTimer = setTimeout(() => this.fail("Session expired"), this.expiresAt - Date.now());
}
async start(): Promise {
try {
this.setPhase("opening");
- const page = await this.createPage(this.config, (poToken) => this.poToken.receive(poToken));
+ this.diagnostics.log(
+ `session start viewport=${this.config.viewportWidth}x${this.config.viewportHeight} headless=${this.config.headless} channel=${this.config.browserChannel ?? "chromium"} locale=${this.config.locale} ttlMs=${this.expiresAt - Date.now()}`,
+ );
+ const page = await this.createPage(this.config, (poToken) => this.receivePoToken(poToken));
if (this.closed) {
void page.close();
return;
}
this.page = page;
+ page.observe((event) => this.diagnostics.log(event));
+ this.diagnostics.log(`page ready url=${describeUrl(page.url())}`);
await this.inputQueue.attach(page.page);
if (this.closed) return;
this.setPhase("awaiting_login");
this.scheduleFrames();
this.scheduleLoginCheck();
- } catch {
+ } catch (error) {
+ this.diagnostics.log(`browser start failed: ${describeError(error)}`);
this.fail("Remote browser failed to start");
}
}
attach(connection: RemoteLoginConnection): void {
if (this.connection) this.connection.close(1000, "Connection replaced");
this.connection = connection;
+ this.diagnostics.attach(connection);
+ this.diagnostics.log("client websocket attached");
this.sendStatus();
this.scheduleFrames();
}
handleMessage(raw: string | Buffer): void {
if (this.closed || typeof raw !== "string") return;
const message = parseRemoteLoginInput(raw);
- if (!message) return;
+ if (!message) {
+ this.diagnostics.log(`rejected client message ${raw.slice(0, 80)}`);
+ return;
+ }
this.inputQueue.enqueue(message);
}
disconnect(): void {
@@ -79,6 +100,10 @@ export class RemoteLoginSession {
cancel(): void {
this.fail("Session cancelled");
}
+ private receivePoToken(poToken: string): void {
+ this.diagnostics.log(`po token observed length=${poToken.length}`);
+ this.poToken.receive(poToken);
+ }
private scheduleLoginCheck(): void {
if (this.closed || this.captureStarted) return;
this.loginTimer = setTimeout(() => void this.checkLogin(), 1000);
@@ -86,29 +111,45 @@ export class RemoteLoginSession {
private async checkLogin(): Promise {
if (this.closed || this.captureStarted || !this.page) return;
try {
+ await this.logLoginState(this.page);
if (await this.page.hasLoginCookie()) {
void this.captureSession();
} else {
this.scheduleLoginCheck();
}
- } catch {
+ } catch (error) {
+ this.diagnostics.log(`login check failed: ${describeError(error)}`);
this.scheduleLoginCheck();
}
}
+ private async logLoginState(page: RemoteLoginPage): Promise {
+ const state = `url=${describeUrl(page.url())} cookies=${await page.cookieSummary()}`;
+ if (state === this.lastLoginState) return;
+ this.lastLoginState = state;
+ this.diagnostics.log(`login check ${state}`);
+ }
private async captureSession(): Promise {
if (this.closed || !this.page || this.captureStarted) return;
this.captureStarted = true;
+ this.diagnostics.log(`youtube login detected at url=${describeUrl(this.page.url())}`);
this.setPhase("capturing_session");
+ const probeStarted = Date.now();
await this.page.page
- .goto(this.config.probeVideoUrl, {
- waitUntil: "domcontentloaded",
- timeout: 30_000,
- })
- .catch(() => undefined);
+ .goto(this.config.probeVideoUrl, { waitUntil: "domcontentloaded", timeout: 30_000 })
+ .then(() => this.diagnostics.log(`probe page loaded in ${Date.now() - probeStarted}ms`))
+ .catch((error) => this.diagnostics.log(`probe page failed: ${describeError(error)}`));
const poToken = await this.poToken.wait(this.config.potTimeoutMs);
- if (!poToken || this.closed) return this.fail("PO token capture timed out");
+ if (!poToken || this.closed) {
+ this.diagnostics.log(
+ `po token wait ended without token after ${Date.now() - probeStarted}ms`,
+ );
+ return this.fail("PO token capture timed out");
+ }
const cookies = await this.page.cookies();
const authUser = await this.page.authUser();
+ this.diagnostics.log(
+ `captured cookies=${cookies.split("\n").length - 1} lines bytes=${cookies.length} authUser=${authUser} poToken=${poToken.length} chars`,
+ );
const sent = await sendRemoteLoginCompletion(
this.target,
this.sessionId,
@@ -117,6 +158,7 @@ export class RemoteLoginSession {
authUser,
this.config,
);
+ this.diagnostics.log(`completion callback ${sent ? "accepted" : "rejected"} by server`);
if (!sent || this.closed) return this.fail("Completion callback failed");
this.setPhase("connected");
setTimeout(() => this.finish(1000, "Connected"), 50);
@@ -131,17 +173,27 @@ export class RemoteLoginSession {
if (this.closed || !this.connection || !this.page) return;
if (this.connection.bufferedAmount() <= this.config.maxBufferedBytes) {
const frame = await this.page.page
- .screenshot({
- type: "jpeg",
- quality: this.config.jpegQuality,
- })
- .catch(() => null);
+ .screenshot({ type: "jpeg", quality: this.config.jpegQuality })
+ .catch((error) => this.reportScreenshotFailure(error));
if (frame && frame.byteLength <= this.config.maxFrameBytes) this.connection.sendBinary(frame);
}
this.scheduleFrames();
}
+ private reportScreenshotFailure(error: unknown): null {
+ this.screenshotFailures += 1;
+ if (
+ this.screenshotFailures === 1 ||
+ this.screenshotFailures % SCREENSHOT_FAILURE_LOG_EVERY === 0
+ ) {
+ this.diagnostics.log(
+ `screenshot failed #${this.screenshotFailures}: ${describeError(error)}`,
+ );
+ }
+ return null;
+ }
private setPhase(phase: RemoteLoginPhase): void {
this.phase = phase;
+ this.diagnostics.log(`phase ${phase}`);
this.sendStatus();
}
private sendStatus(): void {
@@ -149,12 +201,14 @@ export class RemoteLoginSession {
}
private fail(message: string): void {
if (this.closed) return;
+ this.diagnostics.log(`session failed: ${message}`);
this.connection?.sendText(errorMessage(message));
this.finish(1000, message);
}
private finish(code: number, reason: string): void {
if (this.closed) return;
this.closed = true;
+ this.diagnostics.log(`session closed: ${reason}`);
this.inputQueue.close();
clearTimeout(this.expiryTimer);
if (this.frameTimer) clearTimeout(this.frameTimer);
diff --git a/src/token-service.ts b/src/token-service.ts
index 37559ec..dd2c860 100644
--- a/src/token-service.ts
+++ b/src/token-service.ts
@@ -1,9 +1,18 @@
import { fetchChallenge } from "./botguard-challenge.ts";
import { executeBotGuard, mintPoToken, resetBotGuardPage } from "./botguard-page.ts";
import { fetchIntegrityToken, fetchVisitorData } from "./innertube.ts";
+import {
+ type PlaybackTraceContext,
+ playbackTraceEvent,
+ tracePlaybackPhase,
+} from "./playback-diagnostics.ts";
+import {
+ type CachedSession,
+ getVideoBoundPoToken,
+ refreshVideoBoundPoToken,
+} from "./video-bound-po-token.ts";
const EXPIRY_MARGIN_MS = 10 * 60 * 1000;
-const MAX_CACHED_VIDEO_TOKENS = 512;
export type TokenResult = {
visitorData: string;
@@ -17,92 +26,73 @@ export type SessionTokenResult = TokenResult & {
sessionBoundPoToken: string;
};
-type CachedSession = {
- visitorData: string;
- visitorBoundPoToken: string;
- integrityToken: string;
- expiresAt: number;
- videoBoundPoTokens: Map;
- videoBoundPoTokenRequests: Map>;
-};
-
let session: CachedSession | null = null;
let sessionRefreshInFlight: Promise | null = null;
-async function buildSession(): Promise {
- const visitorData = await fetchVisitorData();
- const challenge = await fetchChallenge(visitorData);
+async function buildSession(trace?: PlaybackTraceContext): Promise {
+ const visitorData = await tracePlaybackPhase(trace, "token.visitor_data.fetch", fetchVisitorData);
+ const challenge = await tracePlaybackPhase(trace, "token.botguard.challenge", () =>
+ fetchChallenge(visitorData),
+ );
- const botguardResponse = await executeBotGuard(
- challenge.interpreterScript,
- challenge.program,
- challenge.globalName,
- challenge.eventId,
+ const botguardResponse = await tracePlaybackPhase(trace, "token.botguard.execute", () =>
+ executeBotGuard(
+ challenge.interpreterScript,
+ challenge.program,
+ challenge.globalName,
+ challenge.eventId,
+ ),
+ );
+ const integrityTokenData = await tracePlaybackPhase(trace, "token.generate_it.fetch", () =>
+ fetchIntegrityToken(botguardResponse),
);
- const integrityTokenData = await fetchIntegrityToken(botguardResponse);
- if (!integrityTokenData.integrityToken) {
+ const integrityToken = integrityTokenData.integrityToken;
+ if (!integrityToken) {
throw new Error("integrityToken missing from GenerateIT response");
}
- const visitorBoundPoToken = await mintPoToken(integrityTokenData.integrityToken, visitorData);
+ const visitorBoundPoToken = await tracePlaybackPhase(
+ trace,
+ "token.visitor_bound_po_token.mint",
+ () => mintPoToken(integrityToken, visitorData),
+ );
const ttlMs = Math.max(1000, (integrityTokenData.estimatedTtlSecs ?? 21600) * 1000);
const refreshMarginMs = Math.min(EXPIRY_MARGIN_MS, Math.floor(ttlMs / 10));
return {
visitorData,
visitorBoundPoToken,
- integrityToken: integrityTokenData.integrityToken,
+ integrityToken,
expiresAt: Date.now() + ttlMs - refreshMarginMs,
videoBoundPoTokens: new Map(),
videoBoundPoTokenRequests: new Map(),
};
}
-async function getVideoBoundPoToken(s: CachedSession, videoId: string): Promise {
- const cached = s.videoBoundPoTokens.get(videoId);
- if (cached !== undefined) return cached;
-
- const inFlight = s.videoBoundPoTokenRequests.get(videoId);
- if (inFlight !== undefined) return inFlight;
-
- const request = mintPoToken(s.integrityToken, videoId)
- .then((token) => {
- if (s.videoBoundPoTokens.size >= MAX_CACHED_VIDEO_TOKENS) {
- const oldestVideoId = s.videoBoundPoTokens.keys().next().value;
- if (oldestVideoId !== undefined) {
- s.videoBoundPoTokens.delete(oldestVideoId);
- }
- }
- s.videoBoundPoTokens.set(videoId, token);
- return token;
- })
- .finally(() => s.videoBoundPoTokenRequests.delete(videoId));
- s.videoBoundPoTokenRequests.set(videoId, request);
- return request;
-}
-
-async function refreshVideoBoundPoToken(s: CachedSession, videoId: string): Promise {
- const inFlight = s.videoBoundPoTokenRequests.get(videoId);
- if (inFlight !== undefined) return inFlight;
- s.videoBoundPoTokens.delete(videoId);
- return getVideoBoundPoToken(s, videoId);
-}
-
-function startSessionRefresh(): Promise {
+function startSessionRefresh(trace?: PlaybackTraceContext): Promise {
const previousSession = session;
+ playbackTraceEvent(trace, "session.refresh.start");
const promise = Promise.resolve()
.then(async () => {
if (previousSession !== null) {
await Promise.allSettled(previousSession.videoBoundPoTokenRequests.values());
}
- await resetBotGuardPage();
- return buildSession();
+ await tracePlaybackPhase(trace, "token.botguard.page_reset", resetBotGuardPage);
+ return buildSession(trace);
})
.then((s) => {
session = s;
+ playbackTraceEvent(trace, "session.refresh.end", { outcome: "ok" });
return s;
})
+ .catch((error: unknown) => {
+ playbackTraceEvent(trace, "session.refresh.end", {
+ outcome: "error",
+ errorType: error instanceof Error ? error.name : "unknown",
+ });
+ throw error;
+ })
.finally(() => {
if (sessionRefreshInFlight === promise) sessionRefreshInFlight = null;
});
@@ -110,21 +100,33 @@ function startSessionRefresh(): Promise {
return promise;
}
-export async function getOrRefreshSession(forceRefresh = false): Promise {
- if (sessionRefreshInFlight !== null) return sessionRefreshInFlight;
- if (!forceRefresh && session !== null && Date.now() < session.expiresAt) return session;
- return startSessionRefresh();
+export async function getOrRefreshSession(
+ forceRefresh = false,
+ trace?: PlaybackTraceContext,
+): Promise {
+ if (sessionRefreshInFlight !== null) {
+ playbackTraceEvent(trace, "session.refresh.singleflight_join");
+ return sessionRefreshInFlight;
+ }
+ if (!forceRefresh && session !== null && Date.now() < session.expiresAt) {
+ playbackTraceEvent(trace, "session.cache_hit");
+ return session;
+ }
+ playbackTraceEvent(trace, forceRefresh ? "session.cache_bypass" : "session.cache_miss");
+ return startSessionRefresh(trace);
}
export async function fetchPoToken(
videoId: string,
forceRefresh = false,
refreshVideo = false,
+ trace?: PlaybackTraceContext,
): Promise {
- const currentSession = await getOrRefreshSession(forceRefresh);
+ playbackTraceEvent(trace, "token.video_request", { forceRefresh, refreshVideo });
+ const currentSession = await getOrRefreshSession(forceRefresh, trace);
const videoBoundPoToken = refreshVideo
- ? await refreshVideoBoundPoToken(currentSession, videoId)
- : await getVideoBoundPoToken(currentSession, videoId);
+ ? await refreshVideoBoundPoToken(currentSession, videoId, trace)
+ : await getVideoBoundPoToken(currentSession, videoId, trace);
return tokenResult(currentSession, videoBoundPoToken);
}
@@ -132,14 +134,17 @@ export async function fetchSessionPoTokens(
videoId: string,
sessionBinding: string,
refreshVideo = false,
+ trace?: PlaybackTraceContext,
): Promise {
- const currentSession = await getOrRefreshSession();
+ playbackTraceEvent(trace, "token.session_request", { refreshVideo });
+ const currentSession = await getOrRefreshSession(false, trace);
const [videoBoundPoToken, sessionBoundPoToken] = await Promise.all([
refreshVideo
- ? refreshVideoBoundPoToken(currentSession, videoId)
- : getVideoBoundPoToken(currentSession, videoId),
- getVideoBoundPoToken(currentSession, sessionBinding),
+ ? refreshVideoBoundPoToken(currentSession, videoId, trace)
+ : getVideoBoundPoToken(currentSession, videoId, trace),
+ getVideoBoundPoToken(currentSession, sessionBinding, trace, "session_binding"),
]);
+ playbackTraceEvent(trace, "token.session_tokens.ready");
return {
...tokenResult(currentSession, videoBoundPoToken),
sessionBoundPoToken,
diff --git a/src/video-bound-po-token.ts b/src/video-bound-po-token.ts
new file mode 100644
index 0000000..9359cc5
--- /dev/null
+++ b/src/video-bound-po-token.ts
@@ -0,0 +1,75 @@
+import { mintPoToken } from "./botguard-page.ts";
+import {
+ type PlaybackTraceContext,
+ playbackTraceEvent,
+ tracePlaybackPhase,
+} from "./playback-diagnostics.ts";
+
+const MAX_CACHED_VIDEO_TOKENS = 512;
+
+export type CachedSession = {
+ visitorData: string;
+ visitorBoundPoToken: string;
+ integrityToken: string;
+ expiresAt: number;
+ videoBoundPoTokens: Map;
+ videoBoundPoTokenRequests: Map>;
+};
+
+type TokenTarget = "video" | "session_binding";
+
+export async function getVideoBoundPoToken(
+ s: CachedSession,
+ videoId: string,
+ trace?: PlaybackTraceContext,
+ target: TokenTarget = "video",
+): Promise {
+ const eventPrefix =
+ target === "session_binding" ? "token.session_bound_po_token" : "token.video_bound_po_token";
+ const cached = s.videoBoundPoTokens.get(videoId);
+ if (cached !== undefined) {
+ playbackTraceEvent(trace, eventPrefix + ".cache_hit");
+ return cached;
+ }
+
+ const inFlight = s.videoBoundPoTokenRequests.get(videoId);
+ if (inFlight !== undefined) {
+ playbackTraceEvent(trace, eventPrefix + ".singleflight_join");
+ return inFlight;
+ }
+
+ const request = tracePlaybackPhase(trace, eventPrefix + ".mint", () =>
+ mintPoToken(s.integrityToken, videoId),
+ )
+ .then((token) => {
+ if (s.videoBoundPoTokens.size >= MAX_CACHED_VIDEO_TOKENS) {
+ const oldestVideoId = s.videoBoundPoTokens.keys().next().value;
+ if (oldestVideoId !== undefined) {
+ s.videoBoundPoTokens.delete(oldestVideoId);
+ }
+ }
+ s.videoBoundPoTokens.set(videoId, token);
+ return token;
+ })
+ .finally(() => s.videoBoundPoTokenRequests.delete(videoId));
+ s.videoBoundPoTokenRequests.set(videoId, request);
+ return request;
+}
+
+export async function refreshVideoBoundPoToken(
+ s: CachedSession,
+ videoId: string,
+ trace?: PlaybackTraceContext,
+ target: TokenTarget = "video",
+): Promise {
+ const inFlight = s.videoBoundPoTokenRequests.get(videoId);
+ if (inFlight !== undefined) {
+ playbackTraceEvent(trace, "token.video_bound_po_token.refresh_singleflight_join", {
+ target,
+ });
+ return inFlight;
+ }
+ playbackTraceEvent(trace, "token.video_bound_po_token.refresh", { target });
+ s.videoBoundPoTokens.delete(videoId);
+ return getVideoBoundPoToken(s, videoId, trace, target);
+}
diff --git a/src/youtube-sabr-identity-refresher.ts b/src/youtube-sabr-identity-refresher.ts
index 66272fb..2c7abdb 100644
--- a/src/youtube-sabr-identity-refresher.ts
+++ b/src/youtube-sabr-identity-refresher.ts
@@ -1,3 +1,4 @@
+import type { PlaybackTraceContext } from "./playback-diagnostics.ts";
import { fetchPoToken, type TokenResult } from "./token-service.ts";
import {
getYoutubeInnertube,
@@ -7,7 +8,7 @@ import {
import type { YoutubeSabrClient } from "./youtube-sabr-types.ts";
type YoutubeSabrIdentityDependencies = {
- refreshTokens: (videoId: string) => Promise;
+ refreshTokens: (videoId: string, trace?: PlaybackTraceContext) => Promise;
getSession: (client: YoutubeSabrClient, visitorData: string) => Promise;
invalidateSession: (
client: YoutubeSabrClient,
@@ -24,16 +25,17 @@ export class YoutubeSabrIdentityRefresher {
client: YoutubeSabrClient,
rejectedVisitorData: string,
rejectedSession: Session,
+ trace?: PlaybackTraceContext,
): Promise<{ tokens: TokenResult; session: Session }> {
await this.dependencies.invalidateSession(client, rejectedVisitorData, rejectedSession);
- const tokens = await this.dependencies.refreshTokens(videoId);
+ const tokens = await this.dependencies.refreshTokens(videoId, trace);
const session = await this.dependencies.getSession(client, tokens.visitorData);
return { tokens, session };
}
}
export const youtubeSabrIdentityRefresher = new YoutubeSabrIdentityRefresher({
- refreshTokens: (videoId) => fetchPoToken(videoId, true),
+ refreshTokens: (videoId, trace) => fetchPoToken(videoId, true, false, trace),
getSession: getYoutubeInnertube,
invalidateSession: invalidateYoutubeInnertube,
});
diff --git a/src/youtube-sabr-performance.ts b/src/youtube-sabr-performance.ts
new file mode 100644
index 0000000..59481a1
--- /dev/null
+++ b/src/youtube-sabr-performance.ts
@@ -0,0 +1,58 @@
+import {
+ type PlaybackTraceContext,
+ playbackTraceEvent,
+ tracePlaybackPhase,
+} from "./playback-diagnostics.ts";
+import type { YoutubeSabrClient } from "./youtube-sabr-types.ts";
+
+type Phase = "poToken" | "innertube" | "player" | "identityRefresh" | "sessionBuild";
+type Durations = Record;
+
+export function createYoutubeSabrPerformance(
+ videoId: string,
+ client: YoutubeSabrClient,
+ trace?: PlaybackTraceContext,
+) {
+ const startedAt = performance.now();
+ const durations: Durations = {
+ poToken: 0,
+ innertube: 0,
+ player: 0,
+ identityRefresh: 0,
+ sessionBuild: 0,
+ };
+ let stage: Phase | "complete" = "poToken";
+ let outcome: "error" | "ok" = "error";
+
+ return {
+ async measure(phase: Phase, load: () => Promise): Promise {
+ stage = phase;
+ const phaseStartedAt = performance.now();
+ try {
+ return await tracePlaybackPhase(trace, "sabr." + phase, load);
+ } finally {
+ durations[phase] += Math.round(performance.now() - phaseStartedAt);
+ }
+ },
+ complete() {
+ outcome = "ok";
+ stage = "complete";
+ },
+ log() {
+ const totalMs = Math.round(performance.now() - startedAt);
+ playbackTraceEvent(trace, "sabr.session.summary", {
+ outcome,
+ stage,
+ poTokenMs: durations.poToken,
+ innertubeMs: durations.innertube,
+ playerMs: durations.player,
+ identityRefreshMs: durations.identityRefresh,
+ sessionBuildMs: durations.sessionBuild,
+ totalMs,
+ });
+ console.info(
+ `[sabr-perf] event=youtube_session videoId=${videoId} client=${client} outcome=${outcome} stage=${stage} poTokenMs=${durations.poToken} innertubeMs=${durations.innertube} playerMs=${durations.player} identityRefreshMs=${durations.identityRefresh} sessionBuildMs=${durations.sessionBuild} totalMs=${Math.round(performance.now() - startedAt)}`,
+ );
+ },
+ };
+}
diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts
index d640a68..a3bcf63 100644
--- a/src/youtube-sabr-session.ts
+++ b/src/youtube-sabr-session.ts
@@ -1,6 +1,7 @@
import { buildSabrFormat } from "googlevideo/utils";
import { type IPlayerResponse, YTNodes } from "youtubei.js";
import { KeyedSingleFlight } from "./keyed-single-flight.ts";
+import { type PlaybackTraceContext, playbackTraceEvent } from "./playback-diagnostics.ts";
import { fetchPoToken } from "./token-service.ts";
import { findYoutubeChannelAvatarUrl } from "./youtube-channel-avatar.ts";
import {
@@ -15,65 +16,84 @@ import {
import { withYoutubeClientVersion } from "./youtube-mweb-config.ts";
import { toYoutubeSabrAdaptiveFormat } from "./youtube-sabr-adaptive-format.ts";
import { youtubeSabrIdentityRefresher } from "./youtube-sabr-identity-refresher.ts";
+import { createYoutubeSabrPerformance } from "./youtube-sabr-performance.ts";
import { buildYoutubeSabrPlayerRequest } from "./youtube-sabr-player-request.ts";
import type { YoutubeSabrClient, YoutubeSabrSession } from "./youtube-sabr-types.ts";
const sessionRequests = new KeyedSingleFlight();
+const channelAvatarRequests = new KeyedSingleFlight();
export async function fetchYoutubeSabrSession(
videoId: string,
client: YoutubeSabrClient = "MWEB",
reloadPlaybackParamsToken?: string,
isolated = false,
+ trace?: PlaybackTraceContext,
): Promise {
if (reloadPlaybackParamsToken || isolated) {
- return loadYoutubeSabrSession(videoId, client, reloadPlaybackParamsToken);
+ playbackTraceEvent(trace, "sabr.session.mode", { mode: "isolated" });
+ return loadYoutubeSabrSession(videoId, client, reloadPlaybackParamsToken, trace);
}
- return sessionRequests.run(`${client}:${videoId}`, () => loadYoutubeSabrSession(videoId, client));
+ playbackTraceEvent(trace, "sabr.session.mode", { mode: "singleflight" });
+ return sessionRequests.run(`${client}:${videoId}`, () =>
+ loadYoutubeSabrSession(videoId, client, undefined, trace),
+ );
}
async function loadYoutubeSabrSession(
videoId: string,
client: YoutubeSabrClient,
reloadPlaybackParamsToken?: string,
+ trace?: PlaybackTraceContext,
): Promise {
- let tokens = await fetchPoToken(videoId);
- let innertube = await getYoutubeInnertube(client, tokens.visitorData);
- let responses = await fetchYoutubeResponses(
- videoId,
- innertube,
- tokens.visitorBoundPoToken,
- reloadPlaybackParamsToken,
- );
- const playability = responses.videoInfo.playability_status;
- if (isRejectedAnonymousSession(playability?.status, playability?.reason)) {
- const refreshed = await youtubeSabrIdentityRefresher.refresh(
- videoId,
- client,
- tokens.visitorData,
- innertube,
+ const perf = createYoutubeSabrPerformance(videoId, client, trace);
+ try {
+ let tokens = await perf.measure("poToken", () => fetchPoToken(videoId, false, false, trace));
+
+ let innertube = await perf.measure("innertube", () =>
+ getYoutubeInnertube(client, tokens.visitorData),
);
- tokens = refreshed.tokens;
- innertube = refreshed.session;
- responses = await fetchYoutubeResponses(
- videoId,
- innertube,
- tokens.visitorBoundPoToken,
- reloadPlaybackParamsToken,
+
+ let videoInfo = await perf.measure("player", () =>
+ fetchYoutubeResponses(
+ videoId,
+ innertube,
+ tokens.visitorBoundPoToken,
+ reloadPlaybackParamsToken,
+ ),
);
- }
- const { videoInfo, nextResponse } = responses;
- if (videoInfo.playability_status?.status !== "OK") {
- throw new Error(
- `YouTube ${client} player response is ${videoInfo.playability_status?.status ?? "missing"}: ${videoInfo.playability_status?.reason ?? "no reason"}`,
+ const playability = videoInfo.playability_status;
+ if (isRejectedAnonymousSession(playability?.status, playability?.reason)) {
+ const refreshed = await perf.measure("identityRefresh", () =>
+ youtubeSabrIdentityRefresher.refresh(videoId, client, tokens.visitorData, innertube, trace),
+ );
+ tokens = refreshed.tokens;
+ innertube = refreshed.session;
+ videoInfo = await perf.measure("player", () =>
+ fetchYoutubeResponses(
+ videoId,
+ innertube,
+ tokens.visitorBoundPoToken,
+ reloadPlaybackParamsToken,
+ ),
+ );
+ }
+ if (videoInfo.playability_status?.status !== "OK") {
+ throw new Error(
+ `YouTube ${client} player response is ${videoInfo.playability_status?.status ?? "missing"}: ${videoInfo.playability_status?.reason ?? "no reason"}`,
+ );
+ }
+
+ const channelAvatarUrl = getCachedYoutubeChannelAvatar(videoId) ?? "";
+ if (!channelAvatarUrl) prefetchYoutubeChannelAvatar(videoId, innertube);
+ const session = await perf.measure("sessionBuild", () =>
+ buildYoutubeSabrSession(videoId, client, tokens, innertube, videoInfo, channelAvatarUrl),
);
+ perf.complete();
+ return session;
+ } finally {
+ perf.log();
}
-
- const cachedChannelAvatarUrl = getCachedYoutubeChannelAvatar(videoId);
- const channelAvatarUrl =
- cachedChannelAvatarUrl ?? findYoutubeChannelAvatarUrl(nextResponse?.data);
- cacheYoutubeChannelAvatar(videoId, channelAvatarUrl);
- return buildYoutubeSabrSession(videoId, client, tokens, innertube, videoInfo, channelAvatarUrl);
}
async function fetchYoutubeResponses(
@@ -83,22 +103,27 @@ async function fetchYoutubeResponses(
reloadPlaybackParamsToken?: string,
) {
const endpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } });
- const nextEndpoint = new YTNodes.NavigationEndpoint({ watchNextEndpoint: { videoId } });
- const cachedChannelAvatarUrl = getCachedYoutubeChannelAvatar(videoId);
- const [videoInfo, nextResponse] = await Promise.all([
- endpoint.call(innertube.actions, {
- ...buildYoutubeSabrPlayerRequest(
- innertube.session.player?.signature_timestamp,
- poToken,
- reloadPlaybackParamsToken,
- ),
- parse: true,
- }),
- cachedChannelAvatarUrl
- ? Promise.resolve(null)
- : nextEndpoint.call(innertube.actions, { parse: false }).catch(() => null),
- ]);
- return { videoInfo, nextResponse };
+ return endpoint.call(innertube.actions, {
+ ...buildYoutubeSabrPlayerRequest(
+ innertube.session.player?.signature_timestamp,
+ poToken,
+ reloadPlaybackParamsToken,
+ ),
+ parse: true,
+ });
+}
+
+function prefetchYoutubeChannelAvatar(videoId: string, innertube: YoutubeInnertube): void {
+ if (getCachedYoutubeChannelAvatar(videoId)) return;
+ void channelAvatarRequests
+ .run(videoId, async () => {
+ const endpoint = new YTNodes.NavigationEndpoint({ watchNextEndpoint: { videoId } });
+ const response = await endpoint.call(innertube.actions, { parse: false });
+ const avatarUrl = findYoutubeChannelAvatarUrl(response.data);
+ cacheYoutubeChannelAvatar(videoId, avatarUrl);
+ return avatarUrl;
+ })
+ .catch(() => undefined);
}
async function buildYoutubeSabrSession(
diff --git a/tests/index.test.ts b/tests/index.test.ts
index 775da42..583fba7 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, mock } from "bun:test";
+import type { PlaybackTraceContext } from "../src/playback-diagnostics.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";
@@ -20,6 +21,7 @@ const mockFetchYoutubeSabrSession = mock(
client = "MWEB",
_reloadPlaybackParams?: string,
_isolated = false,
+ _trace?: PlaybackTraceContext,
): Promise => ({
videoId,
client: client === "WEB" ? "WEB" : "MWEB",
@@ -232,7 +234,13 @@ describe("handler", () => {
);
expect(res.status).toBe(200);
- expect(mockFetchYoutubeSabrSession.mock.calls.at(-1)).toEqual(["abc", "MWEB", "reload-secret"]);
+ expect(mockFetchYoutubeSabrSession.mock.calls.at(-1)).toEqual([
+ "abc",
+ "MWEB",
+ "reload-secret",
+ false,
+ undefined,
+ ]);
expect(res.url).not.toContain("reload-secret");
});
diff --git a/tests/po-token-routes.test.ts b/tests/po-token-routes.test.ts
index 1296f90..29af9cf 100644
--- a/tests/po-token-routes.test.ts
+++ b/tests/po-token-routes.test.ts
@@ -37,7 +37,7 @@ describe("POST /potoken/session", () => {
videoBoundPoToken: "video-kids",
sessionBoundPoToken: "session-account-visitor",
});
- expect(fetchSessionPoTokens).toHaveBeenCalledWith("kids", "account-visitor", false);
+ expect(fetchSessionPoTokens).toHaveBeenCalledWith("kids", "account-visitor", false, undefined);
});
it("rejects incomplete and oversized bindings", async () => {
diff --git a/tests/remote-login-browser.test.ts b/tests/remote-login-browser.test.ts
new file mode 100644
index 0000000..1d0a519
--- /dev/null
+++ b/tests/remote-login-browser.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "bun:test";
+import type { Cookie } from "playwright";
+import { isYoutubeLoginCookie, isYoutubeUrl } from "../src/remote-login-browser.ts";
+
+function cookie(domain: string, name: string, value = "v"): Cookie {
+ return {
+ domain,
+ name,
+ value,
+ path: "/",
+ expires: -1,
+ httpOnly: true,
+ secure: true,
+ sameSite: "None",
+ };
+}
+
+describe("remote login browser login detection", () => {
+ it("only treats youtube.com session cookies on youtube pages as a login", () => {
+ expect(isYoutubeLoginCookie(cookie(".youtube.com", "SAPISID"))).toBe(true);
+ expect(isYoutubeLoginCookie(cookie(".google.com", "SID"))).toBe(false);
+ expect(isYoutubeLoginCookie(cookie(".youtube.com", "SID", ""))).toBe(false);
+ expect(isYoutubeLoginCookie(cookie(".youtube.com", "VISITOR_INFO1_LIVE"))).toBe(false);
+ expect(isYoutubeUrl("https://www.youtube.com/")).toBe(true);
+ expect(isYoutubeUrl("https://accounts.google.com/v3/signin/challenge/totp")).toBe(false);
+ expect(isYoutubeUrl("nope")).toBe(false);
+ });
+});
diff --git a/tests/remote-login-diagnostics.test.ts b/tests/remote-login-diagnostics.test.ts
new file mode 100644
index 0000000..a8faf73
--- /dev/null
+++ b/tests/remote-login-diagnostics.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "bun:test";
+import type { Cookie } from "playwright";
+import {
+ describeCookies,
+ describeError,
+ describeUrl,
+ RemoteLoginDiagnostics,
+} from "../src/remote-login-diagnostics.ts";
+import { logMessage } from "../src/remote-login-messages.ts";
+import type { RemoteLoginConnection } from "../src/remote-login-session-types.ts";
+
+function cookie(domain: string, name: string, value = "v"): Cookie {
+ return {
+ domain,
+ name,
+ value,
+ path: "/",
+ expires: -1,
+ httpOnly: true,
+ secure: true,
+ sameSite: "None",
+ };
+}
+
+function connection(sent: string[]): RemoteLoginConnection {
+ return {
+ sendText: (value) => {
+ sent.push(value);
+ return true;
+ },
+ sendBinary: () => true,
+ bufferedAmount: () => 0,
+ close: () => undefined,
+ };
+}
+
+describe("remote login diagnostics", () => {
+ it("describes cookies by domain with names only", () => {
+ const summary = describeCookies([
+ cookie(".youtube.com", "SID", "secret"),
+ cookie(".google.com", "HSID", "secret"),
+ cookie(".youtube.com", "LOGIN_INFO", "secret"),
+ ]);
+ expect(summary).toBe("google.com=[HSID] youtube.com=[LOGIN_INFO,SID]");
+ expect(summary).not.toContain("secret");
+ expect(describeCookies([])).toBe("none");
+ });
+
+ it("strips query strings from urls and trims errors", () => {
+ expect(describeUrl("https://accounts.google.com/v3/signin/challenge/pwd?flow=x&tok=y")).toBe(
+ "https://accounts.google.com/v3/signin/challenge/pwd",
+ );
+ expect(describeUrl("not a url")).toBe("invalid-url");
+ expect(describeError(new Error("first line\nstack"))).toBe("first line");
+ });
+
+ it("replays buffered lines when a client attaches", () => {
+ const diagnostics = new RemoteLoginDiagnostics("abc");
+ diagnostics.log("before attach");
+ const sent: string[] = [];
+ diagnostics.attach(connection(sent));
+ diagnostics.log("after attach");
+ expect(sent).toHaveLength(2);
+ expect(JSON.parse(sent[0] ?? "")).toMatchObject({ type: "log", message: "before attach" });
+ expect(JSON.parse(sent[1] ?? "")).toMatchObject({ type: "log", message: "after attach" });
+ expect(typeof JSON.parse(sent[1] ?? "").at).toBe("number");
+ });
+
+ it("formats log messages", () => {
+ expect(JSON.parse(logMessage(12, "hello"))).toEqual({ type: "log", at: 12, message: "hello" });
+ });
+});
diff --git a/tests/remote-login-fixtures.ts b/tests/remote-login-fixtures.ts
index 2ddd965..1b294fd 100644
--- a/tests/remote-login-fixtures.ts
+++ b/tests/remote-login-fixtures.ts
@@ -64,6 +64,9 @@ export function fakeRemoteLoginPage(overrides: Partial = {}): R
cookies: async () => "# Netscape HTTP Cookie File",
authUser: async () => 0,
hasLoginCookie: async () => false,
+ url: () => "https://www.youtube.com/signin",
+ cookieSummary: async () => "none",
+ observe: () => undefined,
...overrides,
};
}
diff --git a/tests/remote-login-session-diagnostics.test.ts b/tests/remote-login-session-diagnostics.test.ts
new file mode 100644
index 0000000..544d63c
--- /dev/null
+++ b/tests/remote-login-session-diagnostics.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "bun:test";
+import { RemoteLoginSession } from "../src/remote-login-session.ts";
+import type { RemoteLoginConnection } from "../src/remote-login-session-types.ts";
+import {
+ fakeRemoteLoginPage,
+ remoteLoginTarget,
+ remoteLoginTestConfig,
+} from "./remote-login-fixtures.ts";
+
+function waitFor(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function connection(sent: string[]): RemoteLoginConnection {
+ return {
+ sendText: (value) => {
+ sent.push(value);
+ return true;
+ },
+ sendBinary: () => true,
+ bufferedAmount: () => Number.MAX_SAFE_INTEGER,
+ close: () => undefined,
+ };
+}
+
+function logs(sent: string[]): string[] {
+ return sent
+ .map((line) => JSON.parse(line) as { type: string; message?: string })
+ .filter((message) => message.type === "log")
+ .map((message) => message.message ?? "");
+}
+
+describe("RemoteLoginSession diagnostics", () => {
+ it("streams phases, page events and login checks to the client", async () => {
+ let observer: ((event: string) => void) | null = null;
+ const page = fakeRemoteLoginPage({
+ url: () => "https://accounts.google.com/v3/signin/challenge/totp?x=1",
+ cookieSummary: async () => "google.com=[SID]",
+ observe: (listener) => {
+ observer = listener;
+ },
+ });
+ const session = new RemoteLoginSession({
+ sessionId: "abcdef12-session",
+ userId: "user",
+ expiresAt: Date.now() + 300_000,
+ target: remoteLoginTarget(),
+ config: remoteLoginTestConfig(),
+ createPage: async () => page,
+ onDone: () => undefined,
+ });
+ await session.start();
+ const sent: string[] = [];
+ session.attach(connection(sent));
+ observer?.("navigated https://accounts.google.com/v3/signin/challenge/totp");
+ await waitFor(1100);
+ session.cancel();
+
+ const lines = logs(sent);
+ expect(lines.some((line) => line.startsWith("session start viewport=1280x720"))).toBe(true);
+ expect(lines).toContain("phase opening");
+ expect(lines).toContain("phase awaiting_login");
+ expect(lines).toContain("client websocket attached");
+ expect(lines).toContain("navigated https://accounts.google.com/v3/signin/challenge/totp");
+ expect(lines).toContain(
+ "login check url=https://accounts.google.com/v3/signin/challenge/totp cookies=google.com=[SID]",
+ );
+ expect(lines).toContain("session failed: Session cancelled");
+ expect(sent.some((line) => line.includes('"type":"error"'))).toBe(true);
+ });
+
+ it("reports an input failure with the underlying error", async () => {
+ const page = fakeRemoteLoginPage();
+ page.page.keyboard.insertText = async () => {
+ throw new Error("Execution context was destroyed");
+ };
+ const sent: string[] = [];
+ const session = new RemoteLoginSession({
+ sessionId: "session",
+ userId: "user",
+ expiresAt: Date.now() + 300_000,
+ target: remoteLoginTarget(),
+ config: remoteLoginTestConfig(),
+ createPage: async () => page,
+ onDone: () => undefined,
+ });
+ await session.start();
+ session.attach(connection(sent));
+ session.handleMessage(JSON.stringify({ type: "text", value: "abc" }));
+ await waitFor(0);
+ await waitFor(0);
+
+ expect(logs(sent)).toContain("input text 3 chars failed: Execution context was destroyed");
+ expect(logs(sent)).toContain("session failed: Remote browser input failed");
+ });
+});