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"); + }); +});