From bb5a7b064fa596ed8d811eeb5882bfa40f33ded7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 4 Jul 2026 09:53:22 +0200 Subject: [PATCH 01/28] fix: refresh sabr po tokens --- src/botguard-challenge.ts | 2 +- src/index.ts | 2 +- src/innertube.ts | 3 +- src/token-service.ts | 67 ++++++++++++++++++++++++++----------- tests/index.test.ts | 34 ++++++++++++++----- tests/subtitles.test.ts | 6 ++-- tests/token-service.test.ts | 36 ++++++++++++++++---- 7 files changed, 110 insertions(+), 40 deletions(-) diff --git a/src/botguard-challenge.ts b/src/botguard-challenge.ts index 0210ba7..775fd38 100644 --- a/src/botguard-challenge.ts +++ b/src/botguard-challenge.ts @@ -1,4 +1,4 @@ -const WEB_CLIENT_VERSION = "2.20260114.01.00"; +export const WEB_CLIENT_VERSION = "2.20260630.03.00"; const ATT_GET_URL = "https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false&alt=json"; type AttGetChallenge = { diff --git a/src/index.ts b/src/index.ts index 35609a4..ceba9b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,7 @@ export async function handler( } try { - const result = await fetchPoToken(videoId); + const result = await fetchPoToken(videoId, url.searchParams.get("refresh") === "true"); return Response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Internal error"; diff --git a/src/innertube.ts b/src/innertube.ts index 11f2c54..c706649 100644 --- a/src/innertube.ts +++ b/src/innertube.ts @@ -1,4 +1,5 @@ import type { IntegrityTokenData } from "bgutils-js"; +import { WEB_CLIENT_VERSION } from "./botguard-challenge.ts"; const INNERTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; const WAA_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"; @@ -33,7 +34,7 @@ export async function fetchVisitorData(): Promise { hl: "en", gl: "US", clientName: "WEB", - clientVersion: "2.20240726.00.00", + clientVersion: WEB_CLIENT_VERSION, }, }, }), diff --git a/src/token-service.ts b/src/token-service.ts index a6a35b1..7f7105b 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -5,20 +5,24 @@ import { fetchIntegrityToken, fetchVisitorData } from "./innertube.ts"; const EXPIRY_MARGIN_MS = 10 * 60 * 1000; export type TokenResult = { - poToken: string; visitorData: string; + visitorBoundPoToken: string; + videoBoundPoToken: string; + poToken: string; streamingPot: string; }; type CachedSession = { visitorData: string; - poToken: string; + visitorBoundPoToken: string; integrityToken: string; expiresAt: number; }; let session: CachedSession | null = null; let refreshInFlight: Promise | null = null; +let forcedRefreshInFlight: Promise | null = null; +let refreshGeneration = 0; async function buildSession(): Promise { const visitorData = await fetchVisitorData(); @@ -35,35 +39,58 @@ async function buildSession(): Promise { throw new Error("integrityToken missing from GenerateIT response"); } - const poToken = await mintPoToken(integrityTokenData.integrityToken, visitorData); + const visitorBoundPoToken = await mintPoToken(integrityTokenData.integrityToken, visitorData); const ttlMs = (integrityTokenData.estimatedTtlSecs ?? 21600) * 1000; return { visitorData, - poToken, + visitorBoundPoToken, integrityToken: integrityTokenData.integrityToken, expiresAt: Date.now() + ttlMs - EXPIRY_MARGIN_MS, }; } -export async function getOrRefreshSession(): Promise { - if (session !== null && Date.now() < session.expiresAt) return session; - if (!refreshInFlight) { - refreshInFlight = resetBotGuardPage() - .then(() => buildSession()) - .then((s) => { - session = s; - return s; - }) - .finally(() => { +function startSessionRefresh(forceRefresh: boolean): Promise { + const generation = ++refreshGeneration; + const promise = resetBotGuardPage() + .then(() => buildSession()) + .then((s) => { + if (generation === refreshGeneration) session = s; + return s; + }) + .finally(() => { + if (forceRefresh) { + forcedRefreshInFlight = null; + } else { refreshInFlight = null; - }); + } + }); + + if (forceRefresh) { + forcedRefreshInFlight = promise; + } else { + refreshInFlight = promise; } - return refreshInFlight; + + return promise; } -export async function fetchPoToken(videoId: string): Promise { - const { integrityToken, visitorData, poToken } = await getOrRefreshSession(); - const streamingPot = await mintPoToken(integrityToken, videoId); - return { poToken, visitorData, streamingPot }; +export async function getOrRefreshSession(forceRefresh = false): Promise { + if (!forceRefresh && session !== null && Date.now() < session.expiresAt) return session; + if (forceRefresh) return forcedRefreshInFlight ?? startSessionRefresh(true); + if (forcedRefreshInFlight) return forcedRefreshInFlight; + return refreshInFlight ?? startSessionRefresh(false); +} + +export async function fetchPoToken(videoId: string, forceRefresh = false): Promise { + const { integrityToken, visitorData, visitorBoundPoToken } = + await getOrRefreshSession(forceRefresh); + const videoBoundPoToken = await mintPoToken(integrityToken, videoId); + return { + visitorData, + visitorBoundPoToken, + videoBoundPoToken, + poToken: visitorBoundPoToken, + streamingPot: videoBoundPoToken, + }; } diff --git a/tests/index.test.ts b/tests/index.test.ts index 3610660..231b8e9 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -3,14 +3,18 @@ import type { RawCaptionTrack } from "../src/innertube.ts"; import type { SubtitleTrack } from "../src/subtitles.ts"; import type { TokenResult } from "../src/token-service.ts"; +const mockFetchPoToken = mock( + async (videoId: string, _forceRefresh = false): Promise => ({ + visitorData: "visitor-data", + visitorBoundPoToken: "visitor-bound-token", + videoBoundPoToken: `video-bound-${videoId}`, + poToken: "visitor-bound-token", + streamingPot: `video-bound-${videoId}`, + }), +); + mock.module("../src/token-service.ts", () => ({ - fetchPoToken: mock( - async (videoId: string): Promise => ({ - poToken: `pot-${videoId}`, - visitorData: "visitor-data", - streamingPot: `stream-${videoId}`, - }), - ), + fetchPoToken: mockFetchPoToken, })); mock.module("../src/innertube.ts", () => ({ @@ -45,9 +49,21 @@ describe("handler", () => { expect(res.status).toBe(200); const body = (await res.json()) as TokenResult; - expect(body.poToken).toBe("pot-abc"); expect(body.visitorData).toBe("visitor-data"); - expect(body.streamingPot).toBe("stream-abc"); + expect(body.visitorBoundPoToken).toBe("visitor-bound-token"); + expect(body.videoBoundPoToken).toBe("video-bound-abc"); + expect(body.poToken).toBe("visitor-bound-token"); + expect(body.streamingPot).toBe("video-bound-abc"); + }); + + it("GET /potoken forwards refresh requests", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler( + new Request("http://localhost:8081/potoken?videoId=abc&refresh=true"), + ); + + expect(res.status).toBe(200); + expect(mockFetchPoToken.mock.calls.at(-1)?.[1]).toBe(true); }); it("GET /subtitles without videoId returns 400", async () => { diff --git a/tests/subtitles.test.ts b/tests/subtitles.test.ts index a1c0748..103d67c 100644 --- a/tests/subtitles.test.ts +++ b/tests/subtitles.test.ts @@ -14,13 +14,15 @@ mock.module("../src/botguard-page.ts", () => ({ mock.module("../src/token-service.ts", () => ({ getOrRefreshSession: mock(async () => ({ visitorData: VISITOR_DATA, - poToken: PO_TOKEN, + visitorBoundPoToken: PO_TOKEN, integrityToken: INTEGRITY_TOKEN, expiresAt: Date.now() + 3600_000, })), fetchPoToken: mock(async (videoId: string) => ({ - poToken: PO_TOKEN, visitorData: VISITOR_DATA, + visitorBoundPoToken: PO_TOKEN, + videoBoundPoToken: `pot-${videoId}`, + poToken: PO_TOKEN, streamingPot: `pot-${videoId}`, })), })); diff --git a/tests/token-service.test.ts b/tests/token-service.test.ts index f9ab710..ddc7e62 100644 --- a/tests/token-service.test.ts +++ b/tests/token-service.test.ts @@ -4,6 +4,7 @@ import type { TokenResult } from "../src/token-service.ts"; const VISITOR_DATA = "visitor-data-test-123"; const INTEGRITY_TOKEN = "integrity-token-test-xyz"; +let currentVisitorData = VISITOR_DATA; const mockExecuteBotGuard = mock( async (_script: string, _prog: string, _name: string): Promise => @@ -27,7 +28,7 @@ mock.module("../src/botguard-challenge.ts", () => ({ })); mock.module("../src/innertube.ts", () => ({ - fetchVisitorData: mock(async (): Promise => VISITOR_DATA), + fetchVisitorData: mock(async (): Promise => currentVisitorData), fetchIntegrityToken: mock( async (_response: string): Promise => ({ integrityToken: INTEGRITY_TOKEN, @@ -37,7 +38,7 @@ mock.module("../src/innertube.ts", () => ({ fetchCaptionTracks: mock(async (_videoId: string, _visitorData: string, _poToken: string) => []), })); -let fetchPoToken: (videoId: string) => Promise; +let fetchPoToken: (videoId: string, forceRefresh?: boolean) => Promise; describe("fetchPoToken", () => { beforeAll(async () => { @@ -53,6 +54,10 @@ describe("fetchPoToken", () => { expect(r1.visitorData).toBe(VISITOR_DATA); expect(r2.visitorData).toBe(VISITOR_DATA); + expect(r1.visitorBoundPoToken).toBe(`pot-${VISITOR_DATA}`); + expect(r2.visitorBoundPoToken).toBe(`pot-${VISITOR_DATA}`); + expect(r1.videoBoundPoToken).toBe("pot-concurrent-1"); + expect(r2.videoBoundPoToken).toBe("pot-concurrent-2"); expect(r1.poToken).toBe(`pot-${VISITOR_DATA}`); expect(r2.poToken).toBe(`pot-${VISITOR_DATA}`); expect(r1.streamingPot).toBe("pot-concurrent-1"); @@ -64,6 +69,8 @@ describe("fetchPoToken", () => { const result = await fetchPoToken("video-id-1"); expect(result.visitorData).toBe(VISITOR_DATA); + expect(result.visitorBoundPoToken).toBe(`pot-${VISITOR_DATA}`); + expect(result.videoBoundPoToken).toBe("pot-video-id-1"); expect(result.poToken).toBe(`pot-${VISITOR_DATA}`); expect(result.streamingPot).toBe("pot-video-id-1"); expect(mockExecuteBotGuard.mock.calls.length).toBe(1); @@ -77,19 +84,36 @@ describe("fetchPoToken", () => { expect(mockExecuteBotGuard.mock.calls.length).toBe(callsBefore); }); - it("mints a distinct streamingPot per videoId", async () => { + it("refreshes the BotGuard session when requested", async () => { + const callsBefore = mockExecuteBotGuard.mock.calls.length; + const refreshedVisitorData = `${VISITOR_DATA}-refresh`; + currentVisitorData = refreshedVisitorData; + + const result = await fetchPoToken("video-id-refresh", true); + + expect(mockExecuteBotGuard.mock.calls.length).toBe(callsBefore + 1); + expect(result.visitorData).toBe(refreshedVisitorData); + expect(result.visitorBoundPoToken).toBe(`pot-${refreshedVisitorData}`); + expect(result.videoBoundPoToken).toBe("pot-video-id-refresh"); + }); + + it("mints a distinct videoBoundPoToken per videoId", async () => { const result1 = await fetchPoToken("video-alpha"); const result2 = await fetchPoToken("video-beta"); + expect(result1.videoBoundPoToken).toBe("pot-video-alpha"); + expect(result2.videoBoundPoToken).toBe("pot-video-beta"); + expect(result1.videoBoundPoToken).not.toBe(result2.videoBoundPoToken); expect(result1.streamingPot).toBe("pot-video-alpha"); expect(result2.streamingPot).toBe("pot-video-beta"); expect(result1.streamingPot).not.toBe(result2.streamingPot); }); - it("preserves visitorData and poToken across requests", async () => { + it("preserves visitorData and visitorBoundPoToken across requests", async () => { const result = await fetchPoToken("video-id-3"); - expect(result.visitorData).toBe(VISITOR_DATA); - expect(result.poToken).toBe(`pot-${VISITOR_DATA}`); + expect(result.visitorData).toBe(currentVisitorData); + expect(result.visitorBoundPoToken).toBe(`pot-${currentVisitorData}`); + expect(result.poToken).toBe(`pot-${currentVisitorData}`); }); }); From 7fc85b095de0261fbe6bdb586bee99073807cb37 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 4 Jul 2026 19:23:44 +0200 Subject: [PATCH 02/28] fix: cache sabr video po tokens --- src/token-service.ts | 27 ++++++++++++++++++++++++--- tests/token-service.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/token-service.ts b/src/token-service.ts index 7f7105b..fc87004 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -17,6 +17,8 @@ type CachedSession = { visitorBoundPoToken: string; integrityToken: string; expiresAt: number; + videoBoundPoTokens: Map; + videoBoundPoTokenRequests: Map>; }; let session: CachedSession | null = null; @@ -47,9 +49,28 @@ async function buildSession(): Promise { visitorBoundPoToken, integrityToken: integrityTokenData.integrityToken, expiresAt: Date.now() + ttlMs - EXPIRY_MARGIN_MS, + 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) => { + s.videoBoundPoTokens.set(videoId, token); + return token; + }) + .finally(() => s.videoBoundPoTokenRequests.delete(videoId)); + s.videoBoundPoTokenRequests.set(videoId, request); + return request; +} + function startSessionRefresh(forceRefresh: boolean): Promise { const generation = ++refreshGeneration; const promise = resetBotGuardPage() @@ -83,9 +104,9 @@ export async function getOrRefreshSession(forceRefresh = false): Promise { - const { integrityToken, visitorData, visitorBoundPoToken } = - await getOrRefreshSession(forceRefresh); - const videoBoundPoToken = await mintPoToken(integrityToken, videoId); + const currentSession = await getOrRefreshSession(forceRefresh); + const { visitorData, visitorBoundPoToken } = currentSession; + const videoBoundPoToken = await getVideoBoundPoToken(currentSession, videoId); return { visitorData, visitorBoundPoToken, diff --git a/tests/token-service.test.ts b/tests/token-service.test.ts index ddc7e62..09b280a 100644 --- a/tests/token-service.test.ts +++ b/tests/token-service.test.ts @@ -109,6 +109,30 @@ describe("fetchPoToken", () => { expect(result1.streamingPot).not.toBe(result2.streamingPot); }); + it("reuses cached videoBoundPoToken for the same videoId", async () => { + const callsBefore = mockMintPoToken.mock.calls.length; + + const result1 = await fetchPoToken("video-cache"); + const result2 = await fetchPoToken("video-cache"); + + expect(result1.videoBoundPoToken).toBe("pot-video-cache"); + expect(result2.videoBoundPoToken).toBe("pot-video-cache"); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); + }); + + it("deduplicates concurrent videoBoundPoToken mints for the same videoId", async () => { + const callsBefore = mockMintPoToken.mock.calls.length; + + const [result1, result2] = await Promise.all([ + fetchPoToken("video-concurrent-cache"), + fetchPoToken("video-concurrent-cache"), + ]); + + expect(result1.videoBoundPoToken).toBe("pot-video-concurrent-cache"); + expect(result2.videoBoundPoToken).toBe("pot-video-concurrent-cache"); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); + }); + it("preserves visitorData and visitorBoundPoToken across requests", async () => { const result = await fetchPoToken("video-id-3"); From 857c4f53c4f8fd44c6bdc2f91febc378e6ba4539 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 6 Jul 2026 09:07:38 +0200 Subject: [PATCH 03/28] fix: refresh sabr video tokens --- src/index.ts | 6 +++++- src/token-service.ts | 16 ++++++++++++++-- tests/index.test.ts | 12 +++++++++++- tests/token-service.test.ts | 12 +++++++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index ceba9b6..a70cae3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,11 @@ export async function handler( } try { - const result = await fetchPoToken(videoId, url.searchParams.get("refresh") === "true"); + const result = await fetchPoToken( + videoId, + url.searchParams.get("refresh") === "true", + url.searchParams.get("refreshVideo") === "true", + ); return Response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Internal error"; diff --git a/src/token-service.ts b/src/token-service.ts index fc87004..035fe38 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -71,6 +71,12 @@ async function getVideoBoundPoToken(s: CachedSession, videoId: string): Promise< return request; } +async function refreshVideoBoundPoToken(s: CachedSession, videoId: string): Promise { + s.videoBoundPoTokens.delete(videoId); + s.videoBoundPoTokenRequests.delete(videoId); + return getVideoBoundPoToken(s, videoId); +} + function startSessionRefresh(forceRefresh: boolean): Promise { const generation = ++refreshGeneration; const promise = resetBotGuardPage() @@ -103,10 +109,16 @@ export async function getOrRefreshSession(forceRefresh = false): Promise { +export async function fetchPoToken( + videoId: string, + forceRefresh = false, + refreshVideo = false, +): Promise { const currentSession = await getOrRefreshSession(forceRefresh); const { visitorData, visitorBoundPoToken } = currentSession; - const videoBoundPoToken = await getVideoBoundPoToken(currentSession, videoId); + const videoBoundPoToken = refreshVideo + ? await refreshVideoBoundPoToken(currentSession, videoId) + : await getVideoBoundPoToken(currentSession, videoId); return { visitorData, visitorBoundPoToken, diff --git a/tests/index.test.ts b/tests/index.test.ts index 231b8e9..c750402 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -4,7 +4,7 @@ import type { SubtitleTrack } from "../src/subtitles.ts"; import type { TokenResult } from "../src/token-service.ts"; const mockFetchPoToken = mock( - async (videoId: string, _forceRefresh = false): Promise => ({ + async (videoId: string, _forceRefresh = false, _refreshVideo = false): Promise => ({ visitorData: "visitor-data", visitorBoundPoToken: "visitor-bound-token", videoBoundPoToken: `video-bound-${videoId}`, @@ -66,6 +66,16 @@ describe("handler", () => { expect(mockFetchPoToken.mock.calls.at(-1)?.[1]).toBe(true); }); + it("GET /potoken forwards video refresh requests", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler( + new Request("http://localhost:8081/potoken?videoId=abc&refreshVideo=true"), + ); + + expect(res.status).toBe(200); + expect(mockFetchPoToken.mock.calls.at(-1)?.[2]).toBe(true); + }); + it("GET /subtitles without videoId returns 400", async () => { const { handler } = await import("../src/index.ts"); const res = await handler(new Request("http://localhost:8081/subtitles")); diff --git a/tests/token-service.test.ts b/tests/token-service.test.ts index 09b280a..8b9091a 100644 --- a/tests/token-service.test.ts +++ b/tests/token-service.test.ts @@ -38,7 +38,7 @@ mock.module("../src/innertube.ts", () => ({ fetchCaptionTracks: mock(async (_videoId: string, _visitorData: string, _poToken: string) => []), })); -let fetchPoToken: (videoId: string, forceRefresh?: boolean) => Promise; +let fetchPoToken: (videoId: string, forceRefresh?: boolean, refreshVideo?: boolean) => Promise; describe("fetchPoToken", () => { beforeAll(async () => { @@ -120,6 +120,16 @@ describe("fetchPoToken", () => { expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); }); + it("refreshes only the video-bound token when requested", async () => { + const callsBefore = mockMintPoToken.mock.calls.length; + const result = await fetchPoToken("video-cache", false, true); + + expect(result.visitorData).toBe(currentVisitorData); + expect(result.visitorBoundPoToken).toBe(`pot-${currentVisitorData}`); + expect(result.videoBoundPoToken).toBe("pot-video-cache"); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); + }); + it("deduplicates concurrent videoBoundPoToken mints for the same videoId", async () => { const callsBefore = mockMintPoToken.mock.calls.length; From c2a55df37851746e0b7b6eda67b0f7c71727b80e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 6 Jul 2026 09:09:33 +0200 Subject: [PATCH 04/28] style: format token service test --- tests/token-service.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/token-service.test.ts b/tests/token-service.test.ts index 8b9091a..935ee29 100644 --- a/tests/token-service.test.ts +++ b/tests/token-service.test.ts @@ -38,7 +38,11 @@ mock.module("../src/innertube.ts", () => ({ fetchCaptionTracks: mock(async (_videoId: string, _visitorData: string, _poToken: string) => []), })); -let fetchPoToken: (videoId: string, forceRefresh?: boolean, refreshVideo?: boolean) => Promise; +let fetchPoToken: ( + videoId: string, + forceRefresh?: boolean, + refreshVideo?: boolean, +) => Promise; describe("fetchPoToken", () => { beforeAll(async () => { From 421da194403ebc58fe31b187a658e573978e22a5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 6 Jul 2026 10:23:50 +0200 Subject: [PATCH 05/28] chore: update token ci actions --- .github/workflows/coverage.yml | 2 +- .github/workflows/docker.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index fd30313..7366d3d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -28,7 +28,7 @@ jobs: - name: Upload coverage artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage path: coverage/ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9f9cf8a..7a5a81b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -51,9 +51,10 @@ jobs: type=raw,value=beta,enable=${{ github.ref_name == 'dev' }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4.1.0 with: platforms: arm64 + cache-image: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 From dde9b90b05e2c897b2b67be04aa3b4d44824e1fa Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 6 Jul 2026 11:35:39 +0200 Subject: [PATCH 06/28] feat: add mweb sabr session metadata --- bun.lock | 12 ++++ package.json | 4 +- src/index.ts | 22 +++++++ src/youtube-sabr-session.ts | 123 ++++++++++++++++++++++++++++++++++++ src/youtube-sabr-types.ts | 85 +++++++++++++++++++++++++ tests/index.test.ts | 52 +++++++++++++++ 6 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 src/youtube-sabr-session.ts create mode 100644 src/youtube-sabr-types.ts diff --git a/bun.lock b/bun.lock index 4931363..bdd7aa8 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,9 @@ "name": "typetype-token", "dependencies": { "bgutils-js": "^3.2.0", + "googlevideo": "^4.0.4", "playwright": "^1.60.0", + "youtubei.js": "^17.2.0", }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -36,18 +38,28 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.1", "", {}, "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg=="], + "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], "bgutils-js": ["bgutils-js@3.2.0", "", {}, "sha512-CacO15JvxbclbLeCAAm9DETGlLuisRGWpPigoRvNsccSCPEC4pwYwA2g2x/pv7Om/sk79d4ib35V5HHmxPBpDg=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "googlevideo": ["googlevideo@4.0.4", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-S/rfuoPBI+qXCEUPJeVhXsHoISMgVhOz8hHSpGWa0OztfHhh+g9EKaEcqAb/+ttO7meoNQNqIy9dfIpz7HPc4g=="], + + "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "youtubei.js": ["youtubei.js@17.2.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0", "fflate": "^0.8.2", "meriyah": "^6.1.4" } }, "sha512-XLNsgRKO1h7t4i9tIMWSQSeWdD7Ujkk5v1m5YCaumaHMhu/xuLqtO3M0Hq7CXNup9HlJ1NGrT1Y+HLIHnL6Ujg=="], } } diff --git a/package.json b/package.json index 70fcf36..acbc949 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ }, "dependencies": { "bgutils-js": "^3.2.0", - "playwright": "^1.60.0" + "googlevideo": "^4.0.4", + "playwright": "^1.60.0", + "youtubei.js": "^17.2.0" }, "devDependencies": { "@biomejs/biome": "^2.4.16", diff --git a/src/index.ts b/src/index.ts index a70cae3..cbbc093 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { } from "./remote-login-routes.ts"; import { fetchSubtitles } from "./subtitles.ts"; import { fetchPoToken } from "./token-service.ts"; +import { fetchYoutubeSabrSession } from "./youtube-sabr-session.ts"; +import type { YoutubeSabrClient } from "./youtube-sabr-types.ts"; const PORT = 8081; const remoteLoginConfig = readRemoteLoginConfig(); @@ -58,6 +60,26 @@ export async function handler( } } + if (req.method === "GET" && url.pathname === "/youtube/sabr/session") { + const videoId = url.searchParams.get("videoId"); + const clientParam = url.searchParams.get("client") ?? "MWEB"; + + if (!videoId) { + return Response.json({ error: "videoId query parameter is required" }, { status: 400 }); + } + if (clientParam !== "WEB" && clientParam !== "MWEB") { + return Response.json({ error: "client must be WEB or MWEB" }, { status: 400 }); + } + + try { + const result = await fetchYoutubeSabrSession(videoId, clientParam as YoutubeSabrClient); + return Response.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + return Response.json({ error: message }, { status: 500 }); + } + } + if (req.method === "GET" && url.pathname === "/health") { return Response.json({ status: "ok" }); } diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts new file mode 100644 index 0000000..18822f7 --- /dev/null +++ b/src/youtube-sabr-session.ts @@ -0,0 +1,123 @@ +import { buildSabrFormat } from "googlevideo/utils"; +import { fetchPoToken } from "./token-service.ts"; +import type { + YoutubeClientContext, + YoutubePlayerResponse, + YoutubeSabrClient, + YoutubeSabrSession, +} from "./youtube-sabr-types.ts"; + +const INNERTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; +const WEB_VERSION = "2.20260630.03.00"; +const MWEB_VERSION = "2.20260205.04.01"; +const WEB_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/131.0.0.0 Safari/537.36"; +const MWEB_USER_AGENT = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"; + +function clientContext(client: YoutubeSabrClient, visitorData: string): YoutubeClientContext { + if (client === "MWEB") { + return { + hl: "en", + gl: "US", + clientName: "MWEB", + clientVersion: MWEB_VERSION, + visitorData, + userAgent: MWEB_USER_AGENT, + clientFormFactor: "SMALL_FORM_FACTOR", + platform: "MOBILE", + }; + } + return { + hl: "en", + gl: "US", + clientName: "WEB", + clientVersion: WEB_VERSION, + visitorData, + userAgent: WEB_USER_AGENT, + }; +} + +function durationMs(lengthSeconds: string | undefined): number | null { + const seconds = Number.parseInt(lengthSeconds ?? "", 10); + return Number.isFinite(seconds) ? seconds * 1000 : null; +} + +async function fetchPlayerResponse( + videoId: string, + context: YoutubeClientContext, + poToken: string, +): Promise { + const clientName = context.clientName === "WEB" ? "1" : "2"; + const response = await fetch( + `https://www.youtube.com/youtubei/v1/player?key=${INNERTUBE_API_KEY}`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-youtube-client-name": clientName, + "x-youtube-client-version": context.clientVersion, + "x-goog-visitor-id": context.visitorData, + origin: "https://www.youtube.com", + "user-agent": context.userAgent, + }, + body: JSON.stringify({ + context: { client: context }, + videoId, + contentCheckOk: true, + racyCheckOk: true, + playbackContext: { + adPlaybackContext: { pyv: true }, + contentPlaybackContext: { signatureTimestamp: 20410 }, + }, + serviceIntegrityDimensions: { poToken }, + }), + }, + ); + if (!response.ok) throw new Error(`YouTube player request failed: ${response.status}`); + return (await response.json()) as YoutubePlayerResponse; +} + +export async function fetchYoutubeSabrSession( + videoId: string, + client: YoutubeSabrClient = "MWEB", +): Promise { + const tokens = await fetchPoToken(videoId); + const context = clientContext(client, tokens.visitorData); + const videoInfo = await fetchPlayerResponse(videoId, context, tokens.streamingPot); + if (videoInfo.playabilityStatus?.status !== "OK") { + throw new Error( + `YouTube ${client} player response is ${videoInfo.playabilityStatus?.status ?? "missing"}: ${videoInfo.playabilityStatus?.reason ?? "no reason"}`, + ); + } + const serverAbrStreamingUrl = videoInfo.streamingData?.serverAbrStreamingUrl; + const videoPlaybackUstreamerConfig = + videoInfo.playerConfig?.mediaCommonConfig?.mediaUstreamerRequestConfig + ?.videoPlaybackUstreamerConfig; + + if (!serverAbrStreamingUrl) { + throw new Error("serverAbrStreamingUrl missing from YouTube player response"); + } + if (!videoPlaybackUstreamerConfig) { + throw new Error("videoPlaybackUstreamerConfig missing from YouTube player response"); + } + + const formats = (videoInfo.streamingData?.adaptiveFormats ?? []) + .map((format) => buildSabrFormat(format)) + .filter((format) => format.mimeType?.includes("audio") || format.mimeType?.includes("video")); + + return { + videoId, + client, + visitorData: tokens.visitorData, + poToken: tokens.visitorBoundPoToken, + streamingPot: tokens.streamingPot, + serverAbrStreamingUrl, + videoPlaybackUstreamerConfig, + durationMs: durationMs(videoInfo.videoDetails?.lengthSeconds), + title: videoInfo.videoDetails?.title ?? null, + formats, + }; +} diff --git a/src/youtube-sabr-types.ts b/src/youtube-sabr-types.ts new file mode 100644 index 0000000..c56fada --- /dev/null +++ b/src/youtube-sabr-types.ts @@ -0,0 +1,85 @@ +import type { SabrFormat } from "googlevideo/shared-types"; + +export type YoutubeSabrClient = "WEB" | "MWEB"; + +export type YoutubeSabrSession = { + videoId: string; + client: YoutubeSabrClient; + visitorData: string; + poToken: string; + streamingPot: string; + serverAbrStreamingUrl: string; + videoPlaybackUstreamerConfig: string; + durationMs: number | null; + title: string | null; + formats: SabrFormat[]; +}; + +export type YoutubeAudioTrack = { + id: string; +}; + +export type YoutubeAdaptiveFormat = { + itag: number; + last_modified_ms?: string; + lastModified?: string; + xtags?: string; + width?: number; + height?: number; + mime_type?: string; + mimeType?: string; + audio_quality?: string; + audioQuality?: string; + bitrate: number; + average_bitrate?: number; + averageBitrate?: number; + quality?: string; + quality_label?: string; + qualityLabel?: string; + audio_track?: YoutubeAudioTrack; + audioTrackId?: string; + approx_duration_ms?: number; + approxDurationMs?: string; + content_length?: number; + contentLength?: string; + is_drc?: boolean; + is_auto_dubbed?: boolean; + is_descriptive?: boolean; + is_dubbed?: boolean; + language?: string | null; + is_original?: boolean; + is_secondary?: boolean; +}; + +export type YoutubePlayerResponse = { + playabilityStatus?: { + status?: string; + reason?: string; + }; + streamingData?: { + adaptiveFormats?: YoutubeAdaptiveFormat[]; + serverAbrStreamingUrl?: string; + }; + playerConfig?: { + mediaCommonConfig?: { + mediaUstreamerRequestConfig?: { + videoPlaybackUstreamerConfig?: string; + }; + }; + }; + videoDetails?: { + lengthSeconds?: string; + title?: string; + }; +}; + +export type YoutubeClientContext = { + hl: string; + gl: string; + clientName: YoutubeSabrClient; + clientVersion: string; + visitorData: string; + userAgent: string; + clientFormFactor?: string; + platform?: string; +}; diff --git a/tests/index.test.ts b/tests/index.test.ts index c750402..5649d80 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, mock } from "bun:test"; import type { RawCaptionTrack } from "../src/innertube.ts"; import type { SubtitleTrack } from "../src/subtitles.ts"; import type { TokenResult } from "../src/token-service.ts"; +import type { YoutubeSabrSession } from "../src/youtube-sabr-session.ts"; const mockFetchPoToken = mock( async (videoId: string, _forceRefresh = false, _refreshVideo = false): Promise => ({ @@ -24,6 +25,23 @@ mock.module("../src/innertube.ts", () => ({ fetchIntegrityToken: mock(async () => ({ integrityToken: "integrity-token" })), })); +mock.module("../src/youtube-sabr-session.ts", () => ({ + fetchYoutubeSabrSession: mock( + async (videoId: string, client = "MWEB"): Promise => ({ + videoId, + client: client === "WEB" ? "WEB" : "MWEB", + visitorData: "visitor-data", + poToken: "visitor-bound-token", + streamingPot: `video-bound-${videoId}`, + serverAbrStreamingUrl: "https://example.test/sabr", + videoPlaybackUstreamerConfig: "ustreamer-config", + durationMs: 1000, + title: "Test video", + formats: [], + }), + ), +})); + describe("handler", () => { it("GET /health returns 200 with status ok", async () => { const { handler } = await import("../src/index.ts"); @@ -94,6 +112,40 @@ describe("handler", () => { expect(Array.isArray(body)).toBe(true); }); + it("GET /youtube/sabr/session without videoId returns 400", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler(new Request("http://localhost:8081/youtube/sabr/session")); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("videoId query parameter is required"); + }); + + it("GET /youtube/sabr/session rejects unsupported clients", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler( + new Request("http://localhost:8081/youtube/sabr/session?videoId=abc&client=ANDROID_VR"), + ); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("client must be WEB or MWEB"); + }); + + it("GET /youtube/sabr/session returns SABR metadata", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler( + new Request("http://localhost:8081/youtube/sabr/session?videoId=abc"), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as YoutubeSabrSession; + expect(body.videoId).toBe("abc"); + expect(body.client).toBe("MWEB"); + expect(body.serverAbrStreamingUrl).toBe("https://example.test/sabr"); + expect(body.videoPlaybackUstreamerConfig).toBe("ustreamer-config"); + }); + it("unknown route returns 404", async () => { const { handler } = await import("../src/index.ts"); const res = await handler(new Request("http://localhost:8081/unknown")); From 0ba0402eec3b63da1ee6c086d3cde192f8e83879 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 6 Jul 2026 11:38:09 +0200 Subject: [PATCH 07/28] chore: deploy token beta after docker build --- .github/workflows/docker.yml | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7a5a81b..41deb4d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -69,3 +69,46 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + deploy-beta: + needs: build-and-push + if: github.ref_name == 'dev' + runs-on: ubuntu-latest + environment: beta + steps: + - name: Check deployment configuration + id: deploy-config + env: + TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} + TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} + TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }} + TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }} + run: | + if [ -z "$TYPE_TYPE_DEPLOY_HOST" ] || [ -z "$TYPE_TYPE_DEPLOY_PORT" ] || [ -z "$TYPE_TYPE_DEPLOY_USER" ] || [ -z "$TYPE_TYPE_DEPLOY_SSH_KEY" ]; then + echo "Deployment secrets are not configured; skipping deployment." + echo "configured=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "configured=true" >> "$GITHUB_OUTPUT" + + - name: Configure SSH + if: steps.deploy-config.outputs.configured == 'true' + env: + TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} + TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} + TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }} + run: | + install -m 700 -d ~/.ssh + install -m 600 /dev/null ~/.ssh/typetype_deploy_key + printf '%s\n' "$TYPE_TYPE_DEPLOY_SSH_KEY" > ~/.ssh/typetype_deploy_key + ssh-keyscan -p "$TYPE_TYPE_DEPLOY_PORT" "$TYPE_TYPE_DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Deploy beta stack + if: steps.deploy-config.outputs.configured == 'true' + env: + TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} + TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} + TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }} + run: | + ssh -i ~/.ssh/typetype_deploy_key -p "$TYPE_TYPE_DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$TYPE_TYPE_DEPLOY_USER@$TYPE_TYPE_DEPLOY_HOST" "/usr/local/sbin/typetype-beta-update" + curl --fail --silent --show-error https://beta.typetype.video/api/health From c474e491f09816405ffd5ad116fc554d48d9cdbf Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 6 Jul 2026 12:56:43 +0200 Subject: [PATCH 08/28] fix: decode mweb sabr sessions --- src/youtube-sabr-session.ts | 125 +++++++++++------------------------- 1 file changed, 39 insertions(+), 86 deletions(-) diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index 18822f7..866bab2 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -1,101 +1,54 @@ import { buildSabrFormat } from "googlevideo/utils"; +import Innertube, { ClientType, Platform, UniversalCache, YTNodes } from "youtubei.js"; import { fetchPoToken } from "./token-service.ts"; -import type { - YoutubeClientContext, - YoutubePlayerResponse, - YoutubeSabrClient, - YoutubeSabrSession, -} from "./youtube-sabr-types.ts"; +import type { YoutubeSabrClient, YoutubeSabrSession } from "./youtube-sabr-types.ts"; -const INNERTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; -const WEB_VERSION = "2.20260630.03.00"; -const MWEB_VERSION = "2.20260205.04.01"; -const WEB_USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/131.0.0.0 Safari/537.36"; -const MWEB_USER_AGENT = - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " + - "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"; - -function clientContext(client: YoutubeSabrClient, visitorData: string): YoutubeClientContext { - if (client === "MWEB") { - return { - hl: "en", - gl: "US", - clientName: "MWEB", - clientVersion: MWEB_VERSION, - visitorData, - userAgent: MWEB_USER_AGENT, - clientFormFactor: "SMALL_FORM_FACTOR", - platform: "MOBILE", - }; - } - return { - hl: "en", - gl: "US", - clientName: "WEB", - clientVersion: WEB_VERSION, - visitorData, - userAgent: WEB_USER_AGENT, +function installPlatformShim(): void { + Platform.shim.eval = async (data, env) => { + const properties = []; + if (env.n) properties.push(`n: exportedVars.nFunction("${env.n}")`); + if (env.sig) properties.push(`sig: exportedVars.sigFunction("${env.sig}")`); + return new Function(`${data.output}\nreturn { ${properties.join(", ")} }`)(); }; } -function durationMs(lengthSeconds: string | undefined): number | null { - const seconds = Number.parseInt(lengthSeconds ?? "", 10); - return Number.isFinite(seconds) ? seconds * 1000 : null; -} - -async function fetchPlayerResponse( - videoId: string, - context: YoutubeClientContext, - poToken: string, -): Promise { - const clientName = context.clientName === "WEB" ? "1" : "2"; - const response = await fetch( - `https://www.youtube.com/youtubei/v1/player?key=${INNERTUBE_API_KEY}`, - { - method: "POST", - headers: { - "content-type": "application/json", - "x-youtube-client-name": clientName, - "x-youtube-client-version": context.clientVersion, - "x-goog-visitor-id": context.visitorData, - origin: "https://www.youtube.com", - "user-agent": context.userAgent, - }, - body: JSON.stringify({ - context: { client: context }, - videoId, - contentCheckOk: true, - racyCheckOk: true, - playbackContext: { - adPlaybackContext: { pyv: true }, - contentPlaybackContext: { signatureTimestamp: 20410 }, - }, - serviceIntegrityDimensions: { poToken }, - }), - }, - ); - if (!response.ok) throw new Error(`YouTube player request failed: ${response.status}`); - return (await response.json()) as YoutubePlayerResponse; -} - export async function fetchYoutubeSabrSession( videoId: string, client: YoutubeSabrClient = "MWEB", ): Promise { const tokens = await fetchPoToken(videoId); - const context = clientContext(client, tokens.visitorData); - const videoInfo = await fetchPlayerResponse(videoId, context, tokens.streamingPot); - if (videoInfo.playabilityStatus?.status !== "OK") { + installPlatformShim(); + const innertube = await Innertube.create({ + cache: new UniversalCache(true), + client_type: client === "MWEB" ? ClientType.MWEB : ClientType.WEB, + }); + const endpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } }); + const videoInfo = await endpoint.call(innertube.actions, { + playbackContext: { + adPlaybackContext: { pyv: true }, + contentPlaybackContext: { + vis: 0, + splay: false, + lactMilliseconds: "-1", + signatureTimestamp: innertube.session.player?.signature_timestamp, + }, + }, + serviceIntegrityDimensions: { poToken: tokens.streamingPot }, + contentCheckOk: true, + racyCheckOk: true, + parse: true, + }); + if (videoInfo.playability_status?.status !== "OK") { throw new Error( - `YouTube ${client} player response is ${videoInfo.playabilityStatus?.status ?? "missing"}: ${videoInfo.playabilityStatus?.reason ?? "no reason"}`, + `YouTube ${client} player response is ${videoInfo.playability_status?.status ?? "missing"}: ${videoInfo.playability_status?.reason ?? "no reason"}`, ); } - const serverAbrStreamingUrl = videoInfo.streamingData?.serverAbrStreamingUrl; + const serverAbrStreamingUrl = await innertube.session.player?.decipher( + videoInfo.streaming_data?.server_abr_streaming_url, + ); const videoPlaybackUstreamerConfig = - videoInfo.playerConfig?.mediaCommonConfig?.mediaUstreamerRequestConfig - ?.videoPlaybackUstreamerConfig; + videoInfo.player_config?.media_common_config.media_ustreamer_request_config + ?.video_playback_ustreamer_config; if (!serverAbrStreamingUrl) { throw new Error("serverAbrStreamingUrl missing from YouTube player response"); @@ -104,7 +57,7 @@ export async function fetchYoutubeSabrSession( throw new Error("videoPlaybackUstreamerConfig missing from YouTube player response"); } - const formats = (videoInfo.streamingData?.adaptiveFormats ?? []) + const formats = (videoInfo.streaming_data?.adaptive_formats ?? []) .map((format) => buildSabrFormat(format)) .filter((format) => format.mimeType?.includes("audio") || format.mimeType?.includes("video")); @@ -116,8 +69,8 @@ export async function fetchYoutubeSabrSession( streamingPot: tokens.streamingPot, serverAbrStreamingUrl, videoPlaybackUstreamerConfig, - durationMs: durationMs(videoInfo.videoDetails?.lengthSeconds), - title: videoInfo.videoDetails?.title ?? null, + durationMs: Number(videoInfo.video_details?.duration ?? 0) * 1000 || null, + title: videoInfo.video_details?.title ?? null, formats, }; } From 2c11c9dc4e7df210f50d9fb031463347b07822cf Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 8 Jul 2026 07:16:01 +0200 Subject: [PATCH 09/28] feat: add youtube player decoder --- src/index.ts | 13 ++++++++ src/youtube-player-decoder.ts | 60 +++++++++++++++++++++++++++++++++++ tests/index.test.ts | 29 +++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 src/youtube-player-decoder.ts diff --git a/src/index.ts b/src/index.ts index cbbc093..d87ff9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { } from "./remote-login-routes.ts"; import { fetchSubtitles } from "./subtitles.ts"; import { fetchPoToken } from "./token-service.ts"; +import { decodeYoutubePlayerBatch } from "./youtube-player-decoder.ts"; import { fetchYoutubeSabrSession } from "./youtube-sabr-session.ts"; import type { YoutubeSabrClient } from "./youtube-sabr-types.ts"; @@ -80,6 +81,18 @@ export async function handler( } } + if (req.method === "POST" && url.pathname === "/youtube/player/decoder") { + try { + const body = (await req.json().catch(() => ({}))) as Parameters< + typeof decodeYoutubePlayerBatch + >[0]; + return Response.json(await decodeYoutubePlayerBatch(body)); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + return Response.json({ error: message }, { status: 500 }); + } + } + if (req.method === "GET" && url.pathname === "/health") { return Response.json({ status: "ok" }); } diff --git a/src/youtube-player-decoder.ts b/src/youtube-player-decoder.ts new file mode 100644 index 0000000..f47fa42 --- /dev/null +++ b/src/youtube-player-decoder.ts @@ -0,0 +1,60 @@ +import Innertube, { Platform, UniversalCache } from "youtubei.js"; + +export type YoutubePlayerDecodeRequest = { + playerId?: string; + signatures?: string[]; + throttlingParameters?: string[]; +}; + +export type YoutubePlayerDecodeResponse = { + playerId: string; + signatureTimestamp: number; + signatures: Record; + throttlingParameters: Record; +}; + +function installPlatformShim(): void { + Platform.shim.eval = async (data, env) => { + const properties = []; + if (env.n) properties.push(`n: exportedVars.nFunction("${env.n}")`); + if (env.sig) properties.push(`sig: exportedVars.sigFunction("${env.sig}")`); + return new Function(`${data.output}\nreturn { ${properties.join(", ")} }`)(); + }; +} + +function safeValues(values: string[] | undefined): string[] { + return Array.isArray(values) ? values.filter((value) => typeof value === "string") : []; +} + +export async function decodeYoutubePlayerBatch( + request: YoutubePlayerDecodeRequest, +): Promise { + installPlatformShim(); + const innertube = await Innertube.create({ cache: new UniversalCache(true) }); + const player = innertube.session.player; + if (!player) throw new Error("YouTube player is unavailable"); + const signatures: Record = {}; + const throttlingParameters: Record = {}; + for (const signature of safeValues(request.signatures)) { + const cipher = new URLSearchParams({ + url: "https://example.com/videoplayback", + sp: "sig", + s: signature, + }).toString(); + const deciphered = new URL(await player.decipher(undefined, cipher, undefined, new Map())); + const value = deciphered.searchParams.get("sig"); + if (value) signatures[signature] = value; + } + for (const throttlingParameter of safeValues(request.throttlingParameters)) { + const url = `https://example.com/videoplayback?n=${encodeURIComponent(throttlingParameter)}`; + const deciphered = new URL(await player.decipher(url, undefined, undefined, new Map())); + const value = deciphered.searchParams.get("n"); + if (value) throttlingParameters[throttlingParameter] = value; + } + return { + playerId: player.player_id, + signatureTimestamp: player.signature_timestamp, + signatures, + throttlingParameters, + }; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 5649d80..75194b4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, mock } from "bun:test"; import type { RawCaptionTrack } from "../src/innertube.ts"; import type { SubtitleTrack } from "../src/subtitles.ts"; import type { TokenResult } from "../src/token-service.ts"; +import type { YoutubePlayerDecodeResponse } from "../src/youtube-player-decoder.ts"; import type { YoutubeSabrSession } from "../src/youtube-sabr-session.ts"; const mockFetchPoToken = mock( @@ -42,6 +43,17 @@ mock.module("../src/youtube-sabr-session.ts", () => ({ ), })); +mock.module("../src/youtube-player-decoder.ts", () => ({ + decodeYoutubePlayerBatch: mock( + async (): Promise => ({ + playerId: "player-id", + signatureTimestamp: 12345, + signatures: { abc: "cba" }, + throttlingParameters: { xyz: "zyx" }, + }), + ), +})); + describe("handler", () => { it("GET /health returns 200 with status ok", async () => { const { handler } = await import("../src/index.ts"); @@ -146,6 +158,23 @@ describe("handler", () => { expect(body.videoPlaybackUstreamerConfig).toBe("ustreamer-config"); }); + it("POST /youtube/player/decoder returns player decode result", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler( + new Request("http://localhost:8081/youtube/player/decoder", { + method: "POST", + body: JSON.stringify({ signatures: ["abc"], throttlingParameters: ["xyz"] }), + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as YoutubePlayerDecodeResponse; + expect(body.playerId).toBe("player-id"); + expect(body.signatureTimestamp).toBe(12345); + expect(body.signatures.abc).toBe("cba"); + expect(body.throttlingParameters.xyz).toBe("zyx"); + }); + it("unknown route returns 404", async () => { const { handler } = await import("../src/index.ts"); const res = await handler(new Request("http://localhost:8081/unknown")); From 5772f91ece1a51b96a0de3699093962c867a2d21 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 9 Jul 2026 15:45:17 +0200 Subject: [PATCH 10/28] fix: reuse botguard po minter --- src/botguard-page.ts | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/botguard-page.ts b/src/botguard-page.ts index 4587fa4..d002a7c 100644 --- a/src/botguard-page.ts +++ b/src/botguard-page.ts @@ -23,6 +23,12 @@ async function ensurePage(): Promise { type BotGuardArgs = { script: string; prog: string; name: string }; type MintArgs = { token: string; id: string }; +type PoTokenMinter = (id: Uint8Array) => Uint8Array | Promise; +type PoTokenGlobal = Record & { + __wpo?: unknown[]; + __poTokenMinter?: PoTokenMinter; + __poTokenMinterIntegrityToken?: string; +}; export async function executeBotGuard( interpreterScript: string, @@ -78,21 +84,26 @@ export async function mintPoToken(integrityToken: string, identifier: string): P const p = await ensurePage(); return p.evaluate( async (args: MintArgs): Promise => { - const g = globalThis as Record; - const wpo = g.__wpo as unknown[]; - const getMinter = wpo[0] as - | ((bytes: Uint8Array) => Promise<(id: Uint8Array) => Promise>) - | undefined; - if (!getMinter) throw new Error("PMD:Undefined"); + const g = globalThis as PoTokenGlobal; + let mintFn = g.__poTokenMinter; + if (!mintFn || g.__poTokenMinterIntegrityToken !== args.token) { + const getMinter = g.__wpo?.[0] as + | ((bytes: Uint8Array) => PoTokenMinter | Promise) + | undefined; + if (!getMinter) throw new Error("PMD:Undefined"); - const b64 = args.token.replace(/-/g, "+").replace(/_/g, "/").replace(/\./g, "="); - const tokenBytes = Uint8Array.from( - atob(b64) - .split("") - .map((c) => c.charCodeAt(0)), - ); - const mintFn = await getMinter(tokenBytes); - if (!(mintFn instanceof Function)) throw new Error("APF:Failed"); + const b64 = args.token.replace(/-/g, "+").replace(/_/g, "/").replace(/\./g, "="); + const tokenBytes = Uint8Array.from( + atob(b64) + .split("") + .map((c) => c.charCodeAt(0)), + ); + const created = await getMinter(tokenBytes); + if (!(created instanceof Function)) throw new Error("APF:Failed"); + mintFn = created as PoTokenMinter; + g.__poTokenMinter = mintFn; + g.__poTokenMinterIntegrityToken = args.token; + } const result = await mintFn(new TextEncoder().encode(args.id)); if (!(result instanceof Uint8Array)) throw new Error("ODM:Invalid"); From 79f6ac577a3ef858aea8b6dee1e8493652950cf8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 10 Jul 2026 00:12:11 +0200 Subject: [PATCH 11/28] fix: mint protected sabr tokens in headful chromium --- src/botguard-challenge.ts | 21 +++++++++++---------- src/botguard-page.ts | 10 ++++++++-- src/token-service.ts | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/botguard-challenge.ts b/src/botguard-challenge.ts index 775fd38..61e2d3d 100644 --- a/src/botguard-challenge.ts +++ b/src/botguard-challenge.ts @@ -1,5 +1,9 @@ -export const WEB_CLIENT_VERSION = "2.20260630.03.00"; -const ATT_GET_URL = "https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false&alt=json"; +export const WEB_CLIENT_VERSION = "2.20260227.01.00"; +const ATT_GET_URL = "https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false"; +const WAA_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/131.0.0.0 Safari/537.3"; type AttGetChallenge = { interpreterJavascript?: { @@ -43,14 +47,15 @@ async function resolveInterpreterScript(challenge: AttGetChallenge): Promise { +export async function fetchChallenge(): Promise { const response = await fetch(ATT_GET_URL, { method: "POST", headers: { "Content-Type": "application/json", - "X-Goog-Visitor-Id": visitorData, - "X-Youtube-Client-Version": WEB_CLIENT_VERSION, - "X-Youtube-Client-Name": "1", + "User-Agent": USER_AGENT, + Accept: "application/json", + "x-goog-api-key": WAA_API_KEY, + "x-user-agent": "grpc-web-javascript/0.1", }, body: JSON.stringify({ engagementType: "ENGAGEMENT_TYPE_UNBOUND", @@ -58,10 +63,6 @@ export async function fetchChallenge(visitorData: string): Promise { if (!browser) { - browser = await chromium.launch({ headless: true }); + browser = await chromium.launch({ + headless: false, + args: ["--disable-blink-features=AutomationControlled"], + }); } if (!page || page.isClosed()) { - page = await browser.newPage(); + page = await browser.newPage({ userAgent: USER_AGENT }); await page.route(ROUTE, (route) => route.fulfill({ contentType: "text/html", body: BLANK_HTML }), ); diff --git a/src/token-service.ts b/src/token-service.ts index 035fe38..c26b563 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -28,7 +28,7 @@ let refreshGeneration = 0; async function buildSession(): Promise { const visitorData = await fetchVisitorData(); - const challenge = await fetchChallenge(visitorData); + const challenge = await fetchChallenge(); const botguardResponse = await executeBotGuard( challenge.interpreterScript, From f7a04b20344880e50a60cd310af59b8bceed21a6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 10 Jul 2026 11:37:01 +0200 Subject: [PATCH 12/28] chore: update token dependencies --- LICENSES/Apache-2.0.txt | 202 ++++++++++++++++++++++++++++++++++++++++ THIRD_PARTY_NOTICES.md | 3 +- biome.json | 2 +- bun.lock | 26 +++--- package.json | 4 +- 5 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 LICENSES/Apache-2.0.txt diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..df11237 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Portions Copyright (c) Microsoft Corporation. + Portions Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 287cee7..de5af5f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,4 +8,5 @@ TypeType Token depends on third-party packages listed in `package.json` and `bun | `playwright` | Apache-2.0 | Browser automation runtime | | `playwright-core` | Apache-2.0 | Playwright runtime dependency | -The Docker runtime image uses `mcr.microsoft.com/playwright:v1.59.1-noble`. +The Docker runtime image uses `mcr.microsoft.com/playwright:v1.61.1-noble`. +The Apache License 2.0 text is included in `LICENSES/Apache-2.0.txt`. diff --git a/biome.json b/biome.json index 55646da..f06b562 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index bdd7aa8..4c2a20e 100644 --- a/bun.lock +++ b/bun.lock @@ -7,11 +7,11 @@ "dependencies": { "bgutils-js": "^3.2.0", "googlevideo": "^4.0.4", - "playwright": "^1.60.0", + "playwright": "^1.61.1", "youtubei.js": "^17.2.0", }, "devDependencies": { - "@biomejs/biome": "^2.4.16", + "@biomejs/biome": "^2.5.3", "bun-types": "^1.3.14", }, }, @@ -20,23 +20,23 @@ "@biomejs/biome", ], "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.1", "", {}, "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg=="], @@ -54,9 +54,9 @@ "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], - "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], - "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index acbc949..98428d8 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,11 @@ "dependencies": { "bgutils-js": "^3.2.0", "googlevideo": "^4.0.4", - "playwright": "^1.60.0", + "playwright": "^1.61.1", "youtubei.js": "^17.2.0" }, "devDependencies": { - "@biomejs/biome": "^2.4.16", + "@biomejs/biome": "^2.5.3", "bun-types": "^1.3.14" }, "trustedDependencies": [ From f93867d7061585e76ed63456a59719276a376a8d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 10 Jul 2026 11:37:01 +0200 Subject: [PATCH 13/28] fix: start token image without compose init --- Dockerfile | 5 +++-- docker-entrypoint.sh | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 42fd35a..1a69c2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,14 @@ COPY package.json bun.lock ./ ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 RUN bun install --frozen-lockfile --production -FROM mcr.microsoft.com/playwright:v1.60.0-noble AS runner +FROM mcr.microsoft.com/playwright:v1.61.1-noble AS runner WORKDIR /app COPY --from=oven/bun:1.3.14-slim /usr/local/bin/bun /usr/local/bin/bun COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist +COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh EXPOSE 8081 ENV NODE_ENV=production ENV YOUTUBE_REMOTE_LOGIN_HEADLESS=false ENV YOUTUBE_REMOTE_LOGIN_DISABLE_AUTOMATION_CONTROLLED=true -CMD ["xvfb-run", "-a", "-s", "-screen 0 1920x1080x24", "bun", "dist/index.js"] +CMD ["./docker-entrypoint.sh"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..2c300ca --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +display_number=99 +Xvfb ":${display_number}" -screen 0 1920x1080x24 -nolisten tcp -ac >/tmp/xvfb.log 2>&1 & +xvfb_pid=$! + +attempt=0 +while [ ! -S "/tmp/.X11-unix/X${display_number}" ]; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 50 ]; then + kill "$xvfb_pid" 2>/dev/null || true + cat /tmp/xvfb.log >&2 + exit 1 + fi + sleep 0.1 +done + +export DISPLAY=":${display_number}" +exec bun dist/index.js From 23481956bc1db8e2e39ec6a3ff5b136a9dea332a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 10 Jul 2026 11:37:01 +0200 Subject: [PATCH 14/28] ci: secure token validation and images --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++++----- .github/workflows/coverage.yml | 34 ---------------------------------- .github/workflows/docker.yml | 25 +++++++++++++++++++------ 3 files changed, 48 insertions(+), 45 deletions(-) delete mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58f3e7e..fe0ed04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,22 +9,46 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: quality: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false - name: Set up Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.3.14 - name: Install dependencies run: bun install --frozen-lockfile + - name: Audit dependencies + run: bun audit --audit-level=high + - name: Lint - run: bunx biome check src tests + run: bun run lint - - name: Test - run: bun test + - name: Test with coverage + run: bun test --coverage --coverage-reporter=lcov + + - name: Build + run: bun run build + + - name: Upload coverage + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: coverage + path: coverage/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 7366d3d..0000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Coverage - -on: - push: - branches: ["dev", "main"] - pull_request: - branches: ["dev", "main"] - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - coverage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Run tests with coverage - run: bun test --coverage --coverage-reporter=lcov - - - name: Upload coverage artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage - path: coverage/ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 41deb4d..e80e73b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,19 +15,29 @@ env: IMAGE_NAME_BETA: ${{ github.repository }}-beta FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +permissions: + contents: read + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-push: runs-on: ubuntu-latest + timeout-minutes: 45 permissions: contents: read packages: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false - name: Log in to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -35,7 +45,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 with: images: | name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},enable=${{ github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/v') }} @@ -51,22 +61,24 @@ jobs: type=raw,value=beta,enable=${{ github.ref_name == 'dev' }} - name: Set up QEMU - uses: docker/setup-qemu-action@v4.1.0 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 with: platforms: arm64 cache-image: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c - name: Build and push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with: context: . platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + provenance: mode=max + sbom: true cache-from: type=gha cache-to: type=gha,mode=max @@ -74,6 +86,7 @@ jobs: needs: build-and-push if: github.ref_name == 'dev' runs-on: ubuntu-latest + timeout-minutes: 15 environment: beta steps: - name: Check deployment configuration From f30a349300049f4e5715b2d0ace9537349919ed6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 10 Jul 2026 14:08:20 +0200 Subject: [PATCH 15/28] feat: expose attested live hls metadata --- src/youtube-sabr-session.ts | 1 + src/youtube-sabr-types.ts | 1 + tests/index.test.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index 866bab2..7a09bc0 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -68,6 +68,7 @@ export async function fetchYoutubeSabrSession( poToken: tokens.visitorBoundPoToken, streamingPot: tokens.streamingPot, serverAbrStreamingUrl, + hlsManifestUrl: videoInfo.streaming_data?.hls_manifest_url ?? null, videoPlaybackUstreamerConfig, durationMs: Number(videoInfo.video_details?.duration ?? 0) * 1000 || null, title: videoInfo.video_details?.title ?? null, diff --git a/src/youtube-sabr-types.ts b/src/youtube-sabr-types.ts index c56fada..be5788c 100644 --- a/src/youtube-sabr-types.ts +++ b/src/youtube-sabr-types.ts @@ -9,6 +9,7 @@ export type YoutubeSabrSession = { poToken: string; streamingPot: string; serverAbrStreamingUrl: string; + hlsManifestUrl: string | null; videoPlaybackUstreamerConfig: string; durationMs: number | null; title: string | null; diff --git a/tests/index.test.ts b/tests/index.test.ts index 75194b4..9f02854 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -35,6 +35,7 @@ mock.module("../src/youtube-sabr-session.ts", () => ({ poToken: "visitor-bound-token", streamingPot: `video-bound-${videoId}`, serverAbrStreamingUrl: "https://example.test/sabr", + hlsManifestUrl: "https://example.test/live.m3u8", videoPlaybackUstreamerConfig: "ustreamer-config", durationMs: 1000, title: "Test video", @@ -155,6 +156,7 @@ describe("handler", () => { expect(body.videoId).toBe("abc"); expect(body.client).toBe("MWEB"); expect(body.serverAbrStreamingUrl).toBe("https://example.test/sabr"); + expect(body.hlsManifestUrl).toBe("https://example.test/live.m3u8"); expect(body.videoPlaybackUstreamerConfig).toBe("ustreamer-config"); }); From 099c22beabf335fc3826bc8c9a7a0df1598018c5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 11 Jul 2026 07:38:09 +0200 Subject: [PATCH 16/28] feat: expose token build version --- .github/workflows/docker.yml | 22 ++++++++++++++++++++++ Dockerfile | 6 ++++++ src/build-info.ts | 9 +++++++++ src/index.ts | 5 +++++ tests/index.test.ts | 8 ++++++++ 5 files changed, 50 insertions(+) create mode 100644 src/build-info.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e80e73b..2ca77e0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -36,6 +36,24 @@ jobs: with: persist-credentials: false + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + + - name: Resolve build metadata + id: build-info + run: | + base_version="$(bun -e 'console.log(require("./package.json").version)')" + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + version="${GITHUB_REF_NAME#v}" + else + channel="${GITHUB_REF_NAME//[^0-9A-Za-z-]/-}" + version="$base_version-$channel.$GITHUB_RUN_NUMBER" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "build-time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + - name: Log in to GitHub Container Registry uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 with: @@ -73,6 +91,10 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with: context: . + build-args: | + BUILD_VERSION=${{ steps.build-info.outputs.version }} + BUILD_REVISION=${{ github.sha }} + BUILD_TIME=${{ steps.build-info.outputs.build-time }} platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile b/Dockerfile index 1a69c2a..09a9526 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,9 @@ ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 RUN bun install --frozen-lockfile --production FROM mcr.microsoft.com/playwright:v1.61.1-noble AS runner +ARG BUILD_VERSION=0.1.0 +ARG BUILD_REVISION=development +ARG BUILD_TIME=unknown WORKDIR /app COPY --from=oven/bun:1.3.14-slim /usr/local/bin/bun /usr/local/bin/bun COPY --from=prod-deps /app/node_modules ./node_modules @@ -20,6 +23,9 @@ COPY --from=builder /app/dist ./dist COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh EXPOSE 8081 ENV NODE_ENV=production +ENV TYPE_TYPE_BUILD_VERSION=$BUILD_VERSION +ENV TYPE_TYPE_BUILD_REVISION=$BUILD_REVISION +ENV TYPE_TYPE_BUILD_TIME=$BUILD_TIME ENV YOUTUBE_REMOTE_LOGIN_HEADLESS=false ENV YOUTUBE_REMOTE_LOGIN_DISABLE_AUTOMATION_CONTROLLED=true CMD ["./docker-entrypoint.sh"] diff --git a/src/build-info.ts b/src/build-info.ts new file mode 100644 index 0000000..bd6b1a5 --- /dev/null +++ b/src/build-info.ts @@ -0,0 +1,9 @@ +const revision = process.env.TYPE_TYPE_BUILD_REVISION ?? "development"; + +export const buildInfo = { + service: "token", + version: process.env.TYPE_TYPE_BUILD_VERSION ?? "0.1.0", + revision, + shortRevision: revision.slice(0, 12), + buildTime: process.env.TYPE_TYPE_BUILD_TIME ?? "unknown", +}; diff --git a/src/index.ts b/src/index.ts index d87ff9a..9de1ad1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import type { Server } from "bun"; +import { buildInfo } from "./build-info.ts"; import { readRemoteLoginConfig } from "./remote-login-config.ts"; import { RemoteLoginManager } from "./remote-login-manager.ts"; import { @@ -97,6 +98,10 @@ export async function handler( return Response.json({ status: "ok" }); } + if (req.method === "GET" && url.pathname === "/version") { + return Response.json(buildInfo); + } + return new Response("Not Found", { status: 404 }); } diff --git a/tests/index.test.ts b/tests/index.test.ts index 9f02854..4af6fac 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -65,6 +65,14 @@ describe("handler", () => { expect(body).toEqual({ status: "ok" }); }); + it("GET /version returns build metadata", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler(new Request("http://localhost:8081/version")); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ service: "token", version: "0.1.0" }); + }); + it("GET /potoken without videoId returns 400", async () => { const { handler } = await import("../src/index.ts"); const res = await handler(new Request("http://localhost:8081/potoken")); From 51c35f764035f83a58f88eaac35bf864cd741372 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 12 Jul 2026 14:35:48 +0200 Subject: [PATCH 17/28] fix: restart xvfb cleanly in token image --- docker-entrypoint.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2c300ca..2dbe026 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,11 +2,16 @@ set -eu display_number=99 +rm -f "/tmp/.X${display_number}-lock" "/tmp/.X11-unix/X${display_number}" Xvfb ":${display_number}" -screen 0 1920x1080x24 -nolisten tcp -ac >/tmp/xvfb.log 2>&1 & xvfb_pid=$! attempt=0 while [ ! -S "/tmp/.X11-unix/X${display_number}" ]; do + if ! kill -0 "$xvfb_pid" 2>/dev/null; then + cat /tmp/xvfb.log >&2 + exit 1 + fi attempt=$((attempt + 1)) if [ "$attempt" -ge 50 ]; then kill "$xvfb_pid" 2>/dev/null || true From 0033f698b9162f4ecf8e87b18152fe6261a28120 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 12 Jul 2026 14:44:33 +0200 Subject: [PATCH 18/28] feat: expose complete sabr adaptive formats --- src/youtube-sabr-adaptive-format.ts | 57 ++++++++++++++++++++++ src/youtube-sabr-session.ts | 7 +++ src/youtube-sabr-types.ts | 29 +++++++++++ tests/index.test.ts | 1 + tests/youtube-sabr-adaptive-format.test.ts | 56 +++++++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 src/youtube-sabr-adaptive-format.ts create mode 100644 tests/youtube-sabr-adaptive-format.test.ts diff --git a/src/youtube-sabr-adaptive-format.ts b/src/youtube-sabr-adaptive-format.ts new file mode 100644 index 0000000..b2f4a4b --- /dev/null +++ b/src/youtube-sabr-adaptive-format.ts @@ -0,0 +1,57 @@ +import type { YoutubeSabrAdaptiveFormat, YoutubeSabrByteRange } from "./youtube-sabr-types.ts"; + +type SourceAudioTrack = { + id: string; + display_name: string; + audio_is_default: boolean; +}; + +type SourceFormat = { + itag: number; + last_modified_ms: string; + xtags?: string; + mime_type: string; + audio_track?: SourceAudioTrack; + quality_label?: string; + audio_quality?: string; + is_drc?: boolean; + width?: number; + height?: number; + bitrate: number; + content_length?: number; + approx_duration_ms: number; + init_range?: YoutubeSabrByteRange; + index_range?: YoutubeSabrByteRange; + decipher(player: Player): Promise; +}; + +export async function toYoutubeSabrAdaptiveFormat( + format: SourceFormat, + player: Player, +): Promise { + const audioTrack = format.audio_track + ? { + id: format.audio_track.id, + displayName: format.audio_track.display_name, + audioIsDefault: format.audio_track.audio_is_default, + } + : undefined; + return { + itag: format.itag, + lastModified: format.last_modified_ms, + xtags: format.xtags, + mimeType: format.mime_type, + audioTrack, + qualityLabel: format.quality_label, + audioQuality: format.audio_quality, + isDrc: format.is_drc ?? false, + width: format.width, + height: format.height, + bitrate: format.bitrate, + contentLength: format.content_length, + approxDurationMs: format.approx_duration_ms, + url: await format.decipher(player), + initRange: format.init_range, + indexRange: format.index_range, + }; +} diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index 7a09bc0..83b531b 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -1,6 +1,7 @@ import { buildSabrFormat } from "googlevideo/utils"; import Innertube, { ClientType, Platform, UniversalCache, YTNodes } from "youtubei.js"; import { fetchPoToken } from "./token-service.ts"; +import { toYoutubeSabrAdaptiveFormat } from "./youtube-sabr-adaptive-format.ts"; import type { YoutubeSabrClient, YoutubeSabrSession } from "./youtube-sabr-types.ts"; function installPlatformShim(): void { @@ -60,6 +61,11 @@ export async function fetchYoutubeSabrSession( const formats = (videoInfo.streaming_data?.adaptive_formats ?? []) .map((format) => buildSabrFormat(format)) .filter((format) => format.mimeType?.includes("audio") || format.mimeType?.includes("video")); + const adaptiveFormats = await Promise.all( + (videoInfo.streaming_data?.adaptive_formats ?? []).map((format) => + toYoutubeSabrAdaptiveFormat(format, innertube.session.player), + ), + ); return { videoId, @@ -73,5 +79,6 @@ export async function fetchYoutubeSabrSession( durationMs: Number(videoInfo.video_details?.duration ?? 0) * 1000 || null, title: videoInfo.video_details?.title ?? null, formats, + adaptiveFormats, }; } diff --git a/src/youtube-sabr-types.ts b/src/youtube-sabr-types.ts index be5788c..2902a26 100644 --- a/src/youtube-sabr-types.ts +++ b/src/youtube-sabr-types.ts @@ -14,6 +14,35 @@ export type YoutubeSabrSession = { durationMs: number | null; title: string | null; formats: SabrFormat[]; + adaptiveFormats: YoutubeSabrAdaptiveFormat[]; +}; + +export type YoutubeSabrAdaptiveFormat = { + itag: number; + lastModified: string; + xtags?: string; + mimeType: string; + audioTrack?: { + id: string; + displayName: string; + audioIsDefault: boolean; + }; + qualityLabel?: string; + audioQuality?: string; + isDrc: boolean; + width?: number; + height?: number; + bitrate: number; + contentLength?: number; + approxDurationMs: number; + url: string; + initRange?: YoutubeSabrByteRange; + indexRange?: YoutubeSabrByteRange; +}; + +export type YoutubeSabrByteRange = { + start: number; + end: number; }; export type YoutubeAudioTrack = { diff --git a/tests/index.test.ts b/tests/index.test.ts index 4af6fac..ca7cad7 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -40,6 +40,7 @@ mock.module("../src/youtube-sabr-session.ts", () => ({ durationMs: 1000, title: "Test video", formats: [], + adaptiveFormats: [], }), ), })); diff --git a/tests/youtube-sabr-adaptive-format.test.ts b/tests/youtube-sabr-adaptive-format.test.ts new file mode 100644 index 0000000..2fe1ac6 --- /dev/null +++ b/tests/youtube-sabr-adaptive-format.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +import { toYoutubeSabrAdaptiveFormat } from "../src/youtube-sabr-adaptive-format.ts"; + +describe("toYoutubeSabrAdaptiveFormat", () => { + it("preserves the PipePipe player-response fields", async () => { + const player = { id: "player" }; + const result = await toYoutubeSabrAdaptiveFormat( + { + itag: 140, + last_modified_ms: "1783775626105235", + xtags: "track-tags", + mime_type: 'audio/mp4; codecs="mp4a.40.2"', + audio_track: { + id: "fr-FR.4", + display_name: "French (original)", + audio_is_default: true, + }, + audio_quality: "AUDIO_QUALITY_MEDIUM", + is_drc: false, + bitrate: 136689, + content_length: 57528535, + approx_duration_ms: 3554626, + init_range: { start: 0, end: 745 }, + index_range: { start: 746, end: 1201 }, + decipher: async (receivedPlayer) => { + expect(receivedPlayer).toBe(player); + return "https://example.test/audio"; + }, + }, + player, + ); + + expect(result).toEqual({ + itag: 140, + lastModified: "1783775626105235", + xtags: "track-tags", + mimeType: 'audio/mp4; codecs="mp4a.40.2"', + audioTrack: { + id: "fr-FR.4", + displayName: "French (original)", + audioIsDefault: true, + }, + qualityLabel: undefined, + audioQuality: "AUDIO_QUALITY_MEDIUM", + isDrc: false, + width: undefined, + height: undefined, + bitrate: 136689, + contentLength: 57528535, + approxDurationMs: 3554626, + url: "https://example.test/audio", + initRange: { start: 0, end: 745 }, + indexRange: { start: 746, end: 1201 }, + }); + }); +}); From f5a20b2f3ff6ffe0a801e88dde650211c766e36c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 12 Jul 2026 14:58:44 +0200 Subject: [PATCH 19/28] fix: preserve raw sabr player urls --- src/youtube-sabr-adaptive-format.ts | 13 +++--- src/youtube-sabr-session.ts | 7 ++-- src/youtube-sabr-types.ts | 4 +- tests/index.test.ts | 1 + tests/youtube-sabr-adaptive-format.test.ts | 46 ++++++++++------------ 5 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/youtube-sabr-adaptive-format.ts b/src/youtube-sabr-adaptive-format.ts index b2f4a4b..0fe865e 100644 --- a/src/youtube-sabr-adaptive-format.ts +++ b/src/youtube-sabr-adaptive-format.ts @@ -6,7 +6,7 @@ type SourceAudioTrack = { audio_is_default: boolean; }; -type SourceFormat = { +type SourceFormat = { itag: number; last_modified_ms: string; xtags?: string; @@ -20,15 +20,13 @@ type SourceFormat = { bitrate: number; content_length?: number; approx_duration_ms: number; + url?: string; + signature_cipher?: string; init_range?: YoutubeSabrByteRange; index_range?: YoutubeSabrByteRange; - decipher(player: Player): Promise; }; -export async function toYoutubeSabrAdaptiveFormat( - format: SourceFormat, - player: Player, -): Promise { +export function toYoutubeSabrAdaptiveFormat(format: SourceFormat): YoutubeSabrAdaptiveFormat { const audioTrack = format.audio_track ? { id: format.audio_track.id, @@ -50,7 +48,8 @@ export async function toYoutubeSabrAdaptiveFormat( bitrate: format.bitrate, contentLength: format.content_length, approxDurationMs: format.approx_duration_ms, - url: await format.decipher(player), + url: format.url, + signatureCipher: format.signature_cipher, initRange: format.init_range, indexRange: format.index_range, }; diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index 83b531b..16f1db8 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -61,10 +61,8 @@ export async function fetchYoutubeSabrSession( const formats = (videoInfo.streaming_data?.adaptive_formats ?? []) .map((format) => buildSabrFormat(format)) .filter((format) => format.mimeType?.includes("audio") || format.mimeType?.includes("video")); - const adaptiveFormats = await Promise.all( - (videoInfo.streaming_data?.adaptive_formats ?? []).map((format) => - toYoutubeSabrAdaptiveFormat(format, innertube.session.player), - ), + const adaptiveFormats = (videoInfo.streaming_data?.adaptive_formats ?? []).map((format) => + toYoutubeSabrAdaptiveFormat(format), ); return { @@ -74,6 +72,7 @@ export async function fetchYoutubeSabrSession( poToken: tokens.visitorBoundPoToken, streamingPot: tokens.streamingPot, serverAbrStreamingUrl, + rawServerAbrStreamingUrl: videoInfo.streaming_data.server_abr_streaming_url, hlsManifestUrl: videoInfo.streaming_data?.hls_manifest_url ?? null, videoPlaybackUstreamerConfig, durationMs: Number(videoInfo.video_details?.duration ?? 0) * 1000 || null, diff --git a/src/youtube-sabr-types.ts b/src/youtube-sabr-types.ts index 2902a26..ec8575d 100644 --- a/src/youtube-sabr-types.ts +++ b/src/youtube-sabr-types.ts @@ -9,6 +9,7 @@ export type YoutubeSabrSession = { poToken: string; streamingPot: string; serverAbrStreamingUrl: string; + rawServerAbrStreamingUrl: string; hlsManifestUrl: string | null; videoPlaybackUstreamerConfig: string; durationMs: number | null; @@ -35,7 +36,8 @@ export type YoutubeSabrAdaptiveFormat = { bitrate: number; contentLength?: number; approxDurationMs: number; - url: string; + url?: string; + signatureCipher?: string; initRange?: YoutubeSabrByteRange; indexRange?: YoutubeSabrByteRange; }; diff --git a/tests/index.test.ts b/tests/index.test.ts index ca7cad7..a6a7bc5 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -35,6 +35,7 @@ mock.module("../src/youtube-sabr-session.ts", () => ({ poToken: "visitor-bound-token", streamingPot: `video-bound-${videoId}`, serverAbrStreamingUrl: "https://example.test/sabr", + rawServerAbrStreamingUrl: "https://example.test/raw-sabr", hlsManifestUrl: "https://example.test/live.m3u8", videoPlaybackUstreamerConfig: "ustreamer-config", durationMs: 1000, diff --git a/tests/youtube-sabr-adaptive-format.test.ts b/tests/youtube-sabr-adaptive-format.test.ts index 2fe1ac6..ea9cef5 100644 --- a/tests/youtube-sabr-adaptive-format.test.ts +++ b/tests/youtube-sabr-adaptive-format.test.ts @@ -3,32 +3,25 @@ import { toYoutubeSabrAdaptiveFormat } from "../src/youtube-sabr-adaptive-format describe("toYoutubeSabrAdaptiveFormat", () => { it("preserves the PipePipe player-response fields", async () => { - const player = { id: "player" }; - const result = await toYoutubeSabrAdaptiveFormat( - { - itag: 140, - last_modified_ms: "1783775626105235", - xtags: "track-tags", - mime_type: 'audio/mp4; codecs="mp4a.40.2"', - audio_track: { - id: "fr-FR.4", - display_name: "French (original)", - audio_is_default: true, - }, - audio_quality: "AUDIO_QUALITY_MEDIUM", - is_drc: false, - bitrate: 136689, - content_length: 57528535, - approx_duration_ms: 3554626, - init_range: { start: 0, end: 745 }, - index_range: { start: 746, end: 1201 }, - decipher: async (receivedPlayer) => { - expect(receivedPlayer).toBe(player); - return "https://example.test/audio"; - }, + const result = toYoutubeSabrAdaptiveFormat({ + itag: 140, + last_modified_ms: "1783775626105235", + xtags: "track-tags", + mime_type: 'audio/mp4; codecs="mp4a.40.2"', + audio_track: { + id: "fr-FR.4", + display_name: "French (original)", + audio_is_default: true, }, - player, - ); + audio_quality: "AUDIO_QUALITY_MEDIUM", + is_drc: false, + bitrate: 136689, + content_length: 57528535, + approx_duration_ms: 3554626, + url: "https://example.test/audio?n=encrypted", + init_range: { start: 0, end: 745 }, + index_range: { start: 746, end: 1201 }, + }); expect(result).toEqual({ itag: 140, @@ -48,7 +41,8 @@ describe("toYoutubeSabrAdaptiveFormat", () => { bitrate: 136689, contentLength: 57528535, approxDurationMs: 3554626, - url: "https://example.test/audio", + url: "https://example.test/audio?n=encrypted", + signatureCipher: undefined, initRange: { start: 0, end: 745 }, indexRange: { start: 746, end: 1201 }, }); From d936e0d4274958acb54a6df1f066fbc203a7f06d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 12 Jul 2026 15:38:42 +0200 Subject: [PATCH 20/28] feat: expose sabr fallback metadata --- src/youtube-sabr-session.ts | 18 ++++++++++++++++-- src/youtube-sabr-types.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index 16f1db8..5a028bc 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -64,6 +64,19 @@ export async function fetchYoutubeSabrSession( const adaptiveFormats = (videoInfo.streaming_data?.adaptive_formats ?? []).map((format) => toYoutubeSabrAdaptiveFormat(format), ); + const details = videoInfo.video_details; + const metadata = { + title: details?.title ?? "", + author: details?.author ?? "", + channelId: details?.channel_id ?? "", + description: details?.short_description ?? "", + durationMs: (details?.duration ?? 0) * 1000, + viewCount: details?.view_count ?? 0, + thumbnailUrl: details?.thumbnail.at(-1)?.url ?? "", + tags: details?.keywords ?? [], + isLive: details?.is_live ?? false, + isLiveContent: details?.is_live_content ?? false, + }; return { videoId, @@ -75,8 +88,9 @@ export async function fetchYoutubeSabrSession( rawServerAbrStreamingUrl: videoInfo.streaming_data.server_abr_streaming_url, hlsManifestUrl: videoInfo.streaming_data?.hls_manifest_url ?? null, videoPlaybackUstreamerConfig, - durationMs: Number(videoInfo.video_details?.duration ?? 0) * 1000 || null, - title: videoInfo.video_details?.title ?? null, + durationMs: metadata.durationMs || null, + title: metadata.title || null, + metadata, formats, adaptiveFormats, }; diff --git a/src/youtube-sabr-types.ts b/src/youtube-sabr-types.ts index ec8575d..7c2d7be 100644 --- a/src/youtube-sabr-types.ts +++ b/src/youtube-sabr-types.ts @@ -14,10 +14,24 @@ export type YoutubeSabrSession = { videoPlaybackUstreamerConfig: string; durationMs: number | null; title: string | null; + metadata: YoutubeSabrMetadata; formats: SabrFormat[]; adaptiveFormats: YoutubeSabrAdaptiveFormat[]; }; +export type YoutubeSabrMetadata = { + title: string; + author: string; + channelId: string; + description: string; + durationMs: number; + viewCount: number; + thumbnailUrl: string; + tags: string[]; + isLive: boolean; + isLiveContent: boolean; +}; + export type YoutubeSabrAdaptiveFormat = { itag: number; lastModified: string; From 1b578995bc1ed3b7df97b72139ad41a4829a32cc Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 12 Jul 2026 15:43:08 +0200 Subject: [PATCH 21/28] fix: select largest sabr thumbnail --- src/youtube-sabr-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index 5a028bc..1791afd 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -72,7 +72,7 @@ export async function fetchYoutubeSabrSession( description: details?.short_description ?? "", durationMs: (details?.duration ?? 0) * 1000, viewCount: details?.view_count ?? 0, - thumbnailUrl: details?.thumbnail.at(-1)?.url ?? "", + thumbnailUrl: details?.thumbnail[0]?.url ?? "", tags: details?.keywords ?? [], isLive: details?.is_live ?? false, isLiveContent: details?.is_live_content ?? false, From 661a4cd8911ed20d3eeec75af9a32823209b5c36 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 13 Jul 2026 07:42:59 +0200 Subject: [PATCH 22/28] fix: preserve sabr channel avatars --- src/youtube-channel-avatar-cache.ts | 22 +++++++ src/youtube-channel-avatar.ts | 51 ++++++++++++++++ src/youtube-sabr-session.ts | 44 +++++++++----- src/youtube-sabr-types.ts | 1 + tests/index.test.ts | 13 ++++ tests/youtube-channel-avatar-cache.test.ts | 20 +++++++ tests/youtube-channel-avatar.test.ts | 70 ++++++++++++++++++++++ 7 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 src/youtube-channel-avatar-cache.ts create mode 100644 src/youtube-channel-avatar.ts create mode 100644 tests/youtube-channel-avatar-cache.test.ts create mode 100644 tests/youtube-channel-avatar.test.ts diff --git a/src/youtube-channel-avatar-cache.ts b/src/youtube-channel-avatar-cache.ts new file mode 100644 index 0000000..50dc95d --- /dev/null +++ b/src/youtube-channel-avatar-cache.ts @@ -0,0 +1,22 @@ +type CachedAvatar = { + url: string; + expiresAt: number; +}; + +const AVATAR_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const avatarCache = new Map(); + +export function getCachedYoutubeChannelAvatar(videoId: string): string | null { + const cached = avatarCache.get(videoId); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + avatarCache.delete(videoId); + return null; + } + return cached.url; +} + +export function cacheYoutubeChannelAvatar(videoId: string, url: string): void { + if (!url) return; + avatarCache.set(videoId, { url, expiresAt: Date.now() + AVATAR_CACHE_TTL_MS }); +} diff --git a/src/youtube-channel-avatar.ts b/src/youtube-channel-avatar.ts new file mode 100644 index 0000000..82c7f0f --- /dev/null +++ b/src/youtube-channel-avatar.ts @@ -0,0 +1,51 @@ +type JsonObject = Record; + +const OWNER_RENDERER_KEYS = ["slimOwnerRenderer", "videoOwnerRenderer"]; +const MAX_SEARCH_DEPTH = 20; + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function findOwnerRenderer(value: unknown, depth: number): JsonObject | null { + if (depth > MAX_SEARCH_DEPTH) return null; + if (Array.isArray(value)) { + for (const item of value) { + const renderer = findOwnerRenderer(item, depth + 1); + if (renderer) return renderer; + } + return null; + } + if (!isJsonObject(value)) return null; + for (const key of OWNER_RENDERER_KEYS) { + const renderer = value[key]; + if (isJsonObject(renderer)) return renderer; + } + for (const child of Object.values(value)) { + const renderer = findOwnerRenderer(child, depth + 1); + if (renderer) return renderer; + } + return null; +} + +function thumbnailWidth(value: JsonObject): number { + return typeof value.width === "number" ? value.width : 0; +} + +export function findYoutubeChannelAvatarUrl(response: unknown): string { + const renderer = findOwnerRenderer(response, 0); + if (!renderer) return ""; + const thumbnail = renderer.thumbnail; + if (!isJsonObject(thumbnail) || !Array.isArray(thumbnail.thumbnails)) return ""; + let bestUrl = ""; + let bestWidth = -1; + for (const candidate of thumbnail.thumbnails) { + if (!isJsonObject(candidate) || typeof candidate.url !== "string") continue; + const width = thumbnailWidth(candidate); + if (width >= bestWidth) { + bestUrl = candidate.url; + bestWidth = width; + } + } + return bestUrl; +} diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index 1791afd..ebb262a 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -1,6 +1,11 @@ import { buildSabrFormat } from "googlevideo/utils"; import Innertube, { ClientType, Platform, UniversalCache, YTNodes } from "youtubei.js"; import { fetchPoToken } from "./token-service.ts"; +import { findYoutubeChannelAvatarUrl } from "./youtube-channel-avatar.ts"; +import { + cacheYoutubeChannelAvatar, + getCachedYoutubeChannelAvatar, +} from "./youtube-channel-avatar-cache.ts"; import { toYoutubeSabrAdaptiveFormat } from "./youtube-sabr-adaptive-format.ts"; import type { YoutubeSabrClient, YoutubeSabrSession } from "./youtube-sabr-types.ts"; @@ -24,21 +29,28 @@ export async function fetchYoutubeSabrSession( client_type: client === "MWEB" ? ClientType.MWEB : ClientType.WEB, }); const endpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } }); - const videoInfo = await endpoint.call(innertube.actions, { - playbackContext: { - adPlaybackContext: { pyv: true }, - contentPlaybackContext: { - vis: 0, - splay: false, - lactMilliseconds: "-1", - signatureTimestamp: innertube.session.player?.signature_timestamp, + const nextEndpoint = new YTNodes.NavigationEndpoint({ watchNextEndpoint: { videoId } }); + const cachedChannelAvatarUrl = getCachedYoutubeChannelAvatar(videoId); + const [videoInfo, nextResponse] = await Promise.all([ + endpoint.call(innertube.actions, { + playbackContext: { + adPlaybackContext: { pyv: true }, + contentPlaybackContext: { + vis: 0, + splay: false, + lactMilliseconds: "-1", + signatureTimestamp: innertube.session.player?.signature_timestamp, + }, }, - }, - serviceIntegrityDimensions: { poToken: tokens.streamingPot }, - contentCheckOk: true, - racyCheckOk: true, - parse: true, - }); + serviceIntegrityDimensions: { poToken: tokens.streamingPot }, + contentCheckOk: true, + racyCheckOk: true, + parse: true, + }), + cachedChannelAvatarUrl + ? Promise.resolve(null) + : nextEndpoint.call(innertube.actions, { parse: false }).catch(() => null), + ]); 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"}`, @@ -65,10 +77,14 @@ export async function fetchYoutubeSabrSession( toYoutubeSabrAdaptiveFormat(format), ); const details = videoInfo.video_details; + const channelAvatarUrl = + cachedChannelAvatarUrl ?? findYoutubeChannelAvatarUrl(nextResponse?.data); + cacheYoutubeChannelAvatar(videoId, channelAvatarUrl); const metadata = { title: details?.title ?? "", author: details?.author ?? "", channelId: details?.channel_id ?? "", + channelAvatarUrl, description: details?.short_description ?? "", durationMs: (details?.duration ?? 0) * 1000, viewCount: details?.view_count ?? 0, diff --git a/src/youtube-sabr-types.ts b/src/youtube-sabr-types.ts index 7c2d7be..44c90c9 100644 --- a/src/youtube-sabr-types.ts +++ b/src/youtube-sabr-types.ts @@ -23,6 +23,7 @@ export type YoutubeSabrMetadata = { title: string; author: string; channelId: string; + channelAvatarUrl: string; description: string; durationMs: number; viewCount: number; diff --git a/tests/index.test.ts b/tests/index.test.ts index a6a7bc5..36ca184 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -40,6 +40,19 @@ mock.module("../src/youtube-sabr-session.ts", () => ({ videoPlaybackUstreamerConfig: "ustreamer-config", durationMs: 1000, title: "Test video", + metadata: { + title: "Test video", + author: "Test channel", + channelId: "channel-id", + channelAvatarUrl: "https://example.test/avatar.jpg", + description: "", + durationMs: 1000, + viewCount: 1, + thumbnailUrl: "https://example.test/thumb.jpg", + tags: [], + isLive: false, + isLiveContent: false, + }, formats: [], adaptiveFormats: [], }), diff --git a/tests/youtube-channel-avatar-cache.test.ts b/tests/youtube-channel-avatar-cache.test.ts new file mode 100644 index 0000000..b886e0e --- /dev/null +++ b/tests/youtube-channel-avatar-cache.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import { + cacheYoutubeChannelAvatar, + getCachedYoutubeChannelAvatar, +} from "../src/youtube-channel-avatar-cache.ts"; + +describe("youtube channel avatar cache", () => { + it("stores non-empty avatar URLs by video", () => { + cacheYoutubeChannelAvatar("video-one", "https://example.test/avatar.jpg"); + + expect(getCachedYoutubeChannelAvatar("video-one")).toBe("https://example.test/avatar.jpg"); + expect(getCachedYoutubeChannelAvatar("video-two")).toBeNull(); + }); + + it("does not cache empty URLs", () => { + cacheYoutubeChannelAvatar("video-empty", ""); + + expect(getCachedYoutubeChannelAvatar("video-empty")).toBeNull(); + }); +}); diff --git a/tests/youtube-channel-avatar.test.ts b/tests/youtube-channel-avatar.test.ts new file mode 100644 index 0000000..1ee1d9a --- /dev/null +++ b/tests/youtube-channel-avatar.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { findYoutubeChannelAvatarUrl } from "../src/youtube-channel-avatar.ts"; + +describe("findYoutubeChannelAvatarUrl", () => { + it("reads the largest MWEB slim owner thumbnail", () => { + const response = { + contents: { + singleColumnWatchNextResults: { + results: { + results: { + contents: [ + { + slimVideoMetadataSectionRenderer: { + contents: [ + { + slimOwnerRenderer: { + thumbnail: { + thumbnails: [ + { url: "small", width: 48 }, + { url: "large", width: 176 }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + }; + + expect(findYoutubeChannelAvatarUrl(response)).toBe("large"); + }); + + it("reads a WEB video owner thumbnail", () => { + const response = { + contents: { + twoColumnWatchNextResults: { + results: { + results: { + contents: [ + { + videoSecondaryInfoRenderer: { + owner: { + videoOwnerRenderer: { + thumbnail: { + thumbnails: [{ url: "web-avatar", width: 88 }], + }, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }; + + expect(findYoutubeChannelAvatarUrl(response)).toBe("web-avatar"); + }); + + it("returns an empty URL for malformed responses", () => { + expect(findYoutubeChannelAvatarUrl(null)).toBe(""); + expect(findYoutubeChannelAvatarUrl({ slimOwnerRenderer: { thumbnail: {} } })).toBe(""); + }); +}); From fdf84df92b18d6ab3779d70fea2088b4afe16cce Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 13 Jul 2026 10:15:05 +0200 Subject: [PATCH 23/28] perf: reuse YouTube player decoder --- src/youtube-player-decoder.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/youtube-player-decoder.ts b/src/youtube-player-decoder.ts index f47fa42..b764923 100644 --- a/src/youtube-player-decoder.ts +++ b/src/youtube-player-decoder.ts @@ -1,5 +1,10 @@ import Innertube, { Platform, UniversalCache } from "youtubei.js"; +type YoutubeInnertube = Awaited>; + +let innertubePromise: Promise | undefined; +let loadedPlayerId: string | undefined; + export type YoutubePlayerDecodeRequest = { playerId?: string; signatures?: string[]; @@ -26,11 +31,28 @@ function safeValues(values: string[] | undefined): string[] { return Array.isArray(values) ? values.filter((value) => typeof value === "string") : []; } +async function getInnertube(playerId: string | undefined): Promise { + if (playerId && loadedPlayerId && playerId !== loadedPlayerId) { + innertubePromise = undefined; + loadedPlayerId = undefined; + } + const pending = innertubePromise ?? Innertube.create({ cache: new UniversalCache(true) }); + innertubePromise = pending; + try { + const innertube = await pending; + loadedPlayerId = innertube.session.player?.player_id; + return innertube; + } catch (error) { + if (innertubePromise === pending) innertubePromise = undefined; + throw error; + } +} + export async function decodeYoutubePlayerBatch( request: YoutubePlayerDecodeRequest, ): Promise { installPlatformShim(); - const innertube = await Innertube.create({ cache: new UniversalCache(true) }); + const innertube = await getInnertube(request.playerId); const player = innertube.session.player; if (!player) throw new Error("YouTube player is unavailable"); const signatures: Record = {}; From d1adda82a8fad81750919b3ea9b12789f07bca22 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 13 Jul 2026 12:45:25 +0200 Subject: [PATCH 24/28] perf: reuse YouTube SABR sessions --- src/keyed-single-flight.ts | 13 ++++++++++ src/youtube-sabr-session.ts | 41 ++++++++++++++++++++++++++++--- tests/keyed-single-flight.test.ts | 32 ++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/keyed-single-flight.ts create mode 100644 tests/keyed-single-flight.test.ts diff --git a/src/keyed-single-flight.ts b/src/keyed-single-flight.ts new file mode 100644 index 0000000..df7e637 --- /dev/null +++ b/src/keyed-single-flight.ts @@ -0,0 +1,13 @@ +export class KeyedSingleFlight { + private readonly pending = new Map>(); + + run(key: Key, load: () => Promise): Promise { + const existing = this.pending.get(key); + if (existing) return existing; + const request = load().finally(() => { + if (this.pending.get(key) === request) this.pending.delete(key); + }); + this.pending.set(key, request); + return request; + } +} diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index ebb262a..65c9a8f 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -1,5 +1,6 @@ import { buildSabrFormat } from "googlevideo/utils"; import Innertube, { ClientType, Platform, UniversalCache, YTNodes } from "youtubei.js"; +import { KeyedSingleFlight } from "./keyed-single-flight.ts"; import { fetchPoToken } from "./token-service.ts"; import { findYoutubeChannelAvatarUrl } from "./youtube-channel-avatar.ts"; import { @@ -9,6 +10,16 @@ import { import { toYoutubeSabrAdaptiveFormat } from "./youtube-sabr-adaptive-format.ts"; import type { YoutubeSabrClient, YoutubeSabrSession } from "./youtube-sabr-types.ts"; +type YoutubeInnertube = Awaited>; + +type SharedInnertube = { + visitorData: string; + pending: Promise; +}; + +const innertubeByClient = new Map(); +const sessionRequests = new KeyedSingleFlight(); + function installPlatformShim(): void { Platform.shim.eval = async (data, env) => { const properties = []; @@ -22,12 +33,36 @@ export async function fetchYoutubeSabrSession( videoId: string, client: YoutubeSabrClient = "MWEB", ): Promise { - const tokens = await fetchPoToken(videoId); - installPlatformShim(); - const innertube = await Innertube.create({ + return sessionRequests.run(`${client}:${videoId}`, () => loadYoutubeSabrSession(videoId, client)); +} + +async function getInnertube( + client: YoutubeSabrClient, + visitorData: string, +): Promise { + const shared = innertubeByClient.get(client); + if (shared?.visitorData === visitorData) return shared.pending; + const pending = Innertube.create({ cache: new UniversalCache(true), client_type: client === "MWEB" ? ClientType.MWEB : ClientType.WEB, + visitor_data: visitorData, }); + innertubeByClient.set(client, { visitorData, pending }); + try { + return await pending; + } catch (error) { + if (innertubeByClient.get(client)?.pending === pending) innertubeByClient.delete(client); + throw error; + } +} + +async function loadYoutubeSabrSession( + videoId: string, + client: YoutubeSabrClient, +): Promise { + const tokens = await fetchPoToken(videoId); + installPlatformShim(); + const innertube = await getInnertube(client, tokens.visitorData); const endpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } }); const nextEndpoint = new YTNodes.NavigationEndpoint({ watchNextEndpoint: { videoId } }); const cachedChannelAvatarUrl = getCachedYoutubeChannelAvatar(videoId); diff --git a/tests/keyed-single-flight.test.ts b/tests/keyed-single-flight.test.ts new file mode 100644 index 0000000..4216c9e --- /dev/null +++ b/tests/keyed-single-flight.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { KeyedSingleFlight } from "../src/keyed-single-flight.ts"; + +describe("KeyedSingleFlight", () => { + it("shares concurrent work for the same key", async () => { + const singleFlight = new KeyedSingleFlight(); + let calls = 0; + const load = async () => { + calls += 1; + await Bun.sleep(10); + return calls; + }; + + const results = await Promise.all([ + singleFlight.run("video", load), + singleFlight.run("video", load), + singleFlight.run("video", load), + ]); + + expect(results).toEqual([1, 1, 1]); + expect(calls).toBe(1); + }); + + it("starts fresh work after completion", async () => { + const singleFlight = new KeyedSingleFlight(); + let calls = 0; + const load = async () => ++calls; + + expect(await singleFlight.run("video", load)).toBe(1); + expect(await singleFlight.run("video", load)).toBe(2); + }); +}); From b6c1e099a737f55ce3117aa2d1d3f6c02b4a4ad2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 13 Jul 2026 22:15:14 +0200 Subject: [PATCH 25/28] fix: serialize BotGuard token refreshes --- src/botguard-challenge.ts | 13 +++- src/token-service.ts | 42 ++++++------- tests/botguard-challenge.test.ts | 45 ++++++++++++++ tests/token-service-refresh.test.ts | 94 +++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 tests/botguard-challenge.test.ts create mode 100644 tests/token-service-refresh.test.ts diff --git a/src/botguard-challenge.ts b/src/botguard-challenge.ts index 61e2d3d..c14046d 100644 --- a/src/botguard-challenge.ts +++ b/src/botguard-challenge.ts @@ -1,4 +1,6 @@ -export const WEB_CLIENT_VERSION = "2.20260227.01.00"; +import { Constants } from "youtubei.js"; + +export const WEB_CLIENT_VERSION = Constants.CLIENTS.WEB.VERSION; const ATT_GET_URL = "https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false"; const WAA_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"; const USER_AGENT = @@ -47,13 +49,16 @@ async function resolveInterpreterScript(challenge: AttGetChallenge): Promise { +export async function fetchChallenge(visitorData: string): Promise { const response = await fetch(ATT_GET_URL, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT, Accept: "application/json", + "X-Goog-Visitor-Id": visitorData, + "X-Youtube-Client-Name": "1", + "X-Youtube-Client-Version": WEB_CLIENT_VERSION, "x-goog-api-key": WAA_API_KEY, "x-user-agent": "grpc-web-javascript/0.1", }, @@ -63,6 +68,10 @@ export async function fetchChallenge(): Promise { client: { clientName: "WEB", clientVersion: WEB_CLIENT_VERSION, + hl: "en", + gl: "US", + utcOffsetMinutes: 0, + visitorData, }, }, }), diff --git a/src/token-service.ts b/src/token-service.ts index c26b563..04692f3 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -3,6 +3,7 @@ import { executeBotGuard, mintPoToken, resetBotGuardPage } from "./botguard-page import { fetchIntegrityToken, fetchVisitorData } from "./innertube.ts"; const EXPIRY_MARGIN_MS = 10 * 60 * 1000; +const MAX_CACHED_VIDEO_TOKENS = 512; export type TokenResult = { visitorData: string; @@ -22,13 +23,11 @@ type CachedSession = { }; let session: CachedSession | null = null; -let refreshInFlight: Promise | null = null; -let forcedRefreshInFlight: Promise | null = null; -let refreshGeneration = 0; +let sessionRefreshInFlight: Promise | null = null; async function buildSession(): Promise { const visitorData = await fetchVisitorData(); - const challenge = await fetchChallenge(); + const challenge = await fetchChallenge(visitorData); const botguardResponse = await executeBotGuard( challenge.interpreterScript, @@ -42,13 +41,14 @@ async function buildSession(): Promise { } const visitorBoundPoToken = await mintPoToken(integrityTokenData.integrityToken, visitorData); - const ttlMs = (integrityTokenData.estimatedTtlSecs ?? 21600) * 1000; + 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, - expiresAt: Date.now() + ttlMs - EXPIRY_MARGIN_MS, + expiresAt: Date.now() + ttlMs - refreshMarginMs, videoBoundPoTokens: new Map(), videoBoundPoTokenRequests: new Map(), }; @@ -63,6 +63,10 @@ async function getVideoBoundPoToken(s: CachedSession, videoId: string): Promise< 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; }) @@ -72,41 +76,29 @@ async function getVideoBoundPoToken(s: CachedSession, videoId: string): Promise< } async function refreshVideoBoundPoToken(s: CachedSession, videoId: string): Promise { + const inFlight = s.videoBoundPoTokenRequests.get(videoId); + if (inFlight !== undefined) return inFlight; s.videoBoundPoTokens.delete(videoId); - s.videoBoundPoTokenRequests.delete(videoId); return getVideoBoundPoToken(s, videoId); } -function startSessionRefresh(forceRefresh: boolean): Promise { - const generation = ++refreshGeneration; +function startSessionRefresh(): Promise { const promise = resetBotGuardPage() .then(() => buildSession()) .then((s) => { - if (generation === refreshGeneration) session = s; + session = s; return s; }) .finally(() => { - if (forceRefresh) { - forcedRefreshInFlight = null; - } else { - refreshInFlight = null; - } + if (sessionRefreshInFlight === promise) sessionRefreshInFlight = null; }); - - if (forceRefresh) { - forcedRefreshInFlight = promise; - } else { - refreshInFlight = promise; - } - + sessionRefreshInFlight = promise; return promise; } export async function getOrRefreshSession(forceRefresh = false): Promise { if (!forceRefresh && session !== null && Date.now() < session.expiresAt) return session; - if (forceRefresh) return forcedRefreshInFlight ?? startSessionRefresh(true); - if (forcedRefreshInFlight) return forcedRefreshInFlight; - return refreshInFlight ?? startSessionRefresh(false); + return sessionRefreshInFlight ?? startSessionRefresh(); } export async function fetchPoToken( diff --git a/tests/botguard-challenge.test.ts b/tests/botguard-challenge.test.ts new file mode 100644 index 0000000..f444f81 --- /dev/null +++ b/tests/botguard-challenge.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { Constants } from "youtubei.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("fetchChallenge", () => { + it("binds att/get to the current visitor session", async () => { + let request: Request | null = null; + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + request = new Request(input, init); + return Response.json({ + bgChallenge: { + interpreterJavascript: { + privateDoNotAccessOrElseSafeScriptWrappedValue: "interpreter", + }, + program: "program", + globalName: "trayride", + }, + }); + }) as typeof fetch; + + const { fetchChallenge, WEB_CLIENT_VERSION } = await import( + "../src/botguard-challenge.ts?contract-test" + ); + const challenge = await fetchChallenge("visitor-session"); + const body = (await request?.json()) as { + context: { client: { visitorData: string; clientVersion: string } }; + }; + + expect(challenge).toEqual({ + interpreterScript: "interpreter", + program: "program", + globalName: "trayride", + }); + expect(WEB_CLIENT_VERSION).toBe(Constants.CLIENTS.WEB.VERSION); + expect(request?.headers.get("X-Goog-Visitor-Id")).toBe("visitor-session"); + expect(request?.headers.get("X-Youtube-Client-Name")).toBe("1"); + expect(body.context.client.visitorData).toBe("visitor-session"); + expect(body.context.client.clientVersion).toBe(Constants.CLIENTS.WEB.VERSION); + }); +}); diff --git a/tests/token-service-refresh.test.ts b/tests/token-service-refresh.test.ts new file mode 100644 index 0000000..5c12dfa --- /dev/null +++ b/tests/token-service-refresh.test.ts @@ -0,0 +1,94 @@ +import { beforeAll, describe, expect, it, mock } from "bun:test"; +import type { IntegrityTokenData } from "bgutils-js"; +import type { TokenResult } from "../src/token-service.ts"; + +let markVisitorDataStarted: (() => void) | undefined; +const visitorDataStarted = new Promise((resolve) => { + markVisitorDataStarted = resolve; +}); +let releaseVisitorData: (() => void) | undefined; +const visitorDataGate = new Promise((resolve) => { + releaseVisitorData = resolve; +}); +const mockExecuteBotGuard = mock(async (): Promise => "botguard-response"); +const mockMintPoToken = mock(async (_token: string, id: string): Promise => `pot-${id}`); +const mockResetBotGuardPage = mock(async (): Promise => undefined); +let visitorDataRequests = 0; + +mock.module("../src/botguard-page.ts", () => ({ + executeBotGuard: mockExecuteBotGuard, + mintPoToken: mockMintPoToken, + resetBotGuardPage: mockResetBotGuardPage, +})); + +mock.module("../src/botguard-challenge.ts", () => ({ + fetchChallenge: mock(async () => ({ + interpreterScript: "interpreter", + program: "program", + globalName: "trayride", + })), +})); + +mock.module("../src/innertube.ts", () => ({ + fetchVisitorData: mock(async (): Promise => { + visitorDataRequests += 1; + if (visitorDataRequests === 1) { + markVisitorDataStarted?.(); + await visitorDataGate; + } + return "visitor-session"; + }), + fetchIntegrityToken: mock( + async (): Promise => ({ + integrityToken: "integrity-token", + estimatedTtlSecs: 60, + }), + ), +})); + +let fetchPoToken: ( + videoId: string, + forceRefresh?: boolean, + refreshVideo?: boolean, +) => Promise; + +describe("token refresh concurrency", () => { + beforeAll(async () => { + const module = await import("../src/token-service.ts?refresh-race-test"); + fetchPoToken = module.fetchPoToken; + }); + + it("joins a forced refresh to an active session refresh", async () => { + const regular = fetchPoToken("regular-video"); + await visitorDataStarted; + const forced = fetchPoToken("forced-video", true); + releaseVisitorData?.(); + + const [regularResult, forcedResult] = await Promise.all([regular, forced]); + + expect(regularResult.visitorData).toBe("visitor-session"); + expect(forcedResult.visitorData).toBe("visitor-session"); + expect(visitorDataRequests).toBe(1); + expect(mockResetBotGuardPage.mock.calls.length).toBe(1); + expect(mockExecuteBotGuard.mock.calls.length).toBe(1); + }); + + it("deduplicates concurrent video token refreshes", async () => { + await fetchPoToken("refresh-video"); + const callsBefore = mockMintPoToken.mock.calls.length; + await Promise.all([ + fetchPoToken("refresh-video", false, true), + fetchPoToken("refresh-video", false, true), + ]); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); + }); + + it("bounds the video token cache", async () => { + for (let index = 0; index <= 512; index += 1) { + await fetchPoToken(`cache-limit-${index}`); + } + const callsBefore = mockMintPoToken.mock.calls.length; + await fetchPoToken("cache-limit-0"); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); + }); +}); From 5eee0ca073cae2b33e296d4006241c501a77f054 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 13 Jul 2026 22:22:26 +0200 Subject: [PATCH 26/28] fix: coalesce video token refreshes --- src/token-service.ts | 17 +++++++++++++++-- tests/token-service-refresh.test.ts | 2 ++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/token-service.ts b/src/token-service.ts index 04692f3..82df19b 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -4,6 +4,7 @@ import { fetchIntegrityToken, fetchVisitorData } from "./innertube.ts"; const EXPIRY_MARGIN_MS = 10 * 60 * 1000; const MAX_CACHED_VIDEO_TOKENS = 512; +const VIDEO_TOKEN_REFRESH_COALESCE_MS = 1000; export type TokenResult = { visitorData: string; @@ -20,6 +21,7 @@ type CachedSession = { expiresAt: number; videoBoundPoTokens: Map; videoBoundPoTokenRequests: Map>; + videoBoundPoTokenRefreshTimes: Map; }; let session: CachedSession | null = null; @@ -51,6 +53,7 @@ async function buildSession(): Promise { expiresAt: Date.now() + ttlMs - refreshMarginMs, videoBoundPoTokens: new Map(), videoBoundPoTokenRequests: new Map(), + videoBoundPoTokenRefreshTimes: new Map(), }; } @@ -65,7 +68,10 @@ async function getVideoBoundPoToken(s: CachedSession, videoId: string): Promise< .then((token) => { if (s.videoBoundPoTokens.size >= MAX_CACHED_VIDEO_TOKENS) { const oldestVideoId = s.videoBoundPoTokens.keys().next().value; - if (oldestVideoId !== undefined) s.videoBoundPoTokens.delete(oldestVideoId); + if (oldestVideoId !== undefined) { + s.videoBoundPoTokens.delete(oldestVideoId); + s.videoBoundPoTokenRefreshTimes.delete(oldestVideoId); + } } s.videoBoundPoTokens.set(videoId, token); return token; @@ -78,8 +84,15 @@ async function getVideoBoundPoToken(s: CachedSession, videoId: string): Promise< async function refreshVideoBoundPoToken(s: CachedSession, videoId: string): Promise { const inFlight = s.videoBoundPoTokenRequests.get(videoId); if (inFlight !== undefined) return inFlight; + const refreshedAt = s.videoBoundPoTokenRefreshTimes.get(videoId); + if (refreshedAt !== undefined && Date.now() - refreshedAt < VIDEO_TOKEN_REFRESH_COALESCE_MS) { + return getVideoBoundPoToken(s, videoId); + } s.videoBoundPoTokens.delete(videoId); - return getVideoBoundPoToken(s, videoId); + return getVideoBoundPoToken(s, videoId).then((token) => { + s.videoBoundPoTokenRefreshTimes.set(videoId, Date.now()); + return token; + }); } function startSessionRefresh(): Promise { diff --git a/tests/token-service-refresh.test.ts b/tests/token-service-refresh.test.ts index 5c12dfa..c8f15f7 100644 --- a/tests/token-service-refresh.test.ts +++ b/tests/token-service-refresh.test.ts @@ -81,6 +81,8 @@ describe("token refresh concurrency", () => { fetchPoToken("refresh-video", false, true), ]); expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); + await fetchPoToken("refresh-video", false, true); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); }); it("bounds the video token cache", async () => { From 5f12561e697f671b5d38f5d5cf0a000a4d7c839e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 13 Jul 2026 23:41:09 +0200 Subject: [PATCH 27/28] fix: coordinate BotGuard session refreshes --- src/token-service.ts | 14 +++++++--- tests/token-service-refresh.test.ts | 41 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/token-service.ts b/src/token-service.ts index 82df19b..ee5bfef 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -96,8 +96,15 @@ async function refreshVideoBoundPoToken(s: CachedSession, videoId: string): Prom } function startSessionRefresh(): Promise { - const promise = resetBotGuardPage() - .then(() => buildSession()) + const previousSession = session; + const promise = Promise.resolve() + .then(async () => { + if (previousSession !== null) { + await Promise.allSettled(previousSession.videoBoundPoTokenRequests.values()); + } + await resetBotGuardPage(); + return buildSession(); + }) .then((s) => { session = s; return s; @@ -110,8 +117,9 @@ function startSessionRefresh(): Promise { } export async function getOrRefreshSession(forceRefresh = false): Promise { + if (sessionRefreshInFlight !== null) return sessionRefreshInFlight; if (!forceRefresh && session !== null && Date.now() < session.expiresAt) return session; - return sessionRefreshInFlight ?? startSessionRefresh(); + return startSessionRefresh(); } export async function fetchPoToken( diff --git a/tests/token-service-refresh.test.ts b/tests/token-service-refresh.test.ts index c8f15f7..a5117bf 100644 --- a/tests/token-service-refresh.test.ts +++ b/tests/token-service-refresh.test.ts @@ -11,8 +11,23 @@ const visitorDataGate = new Promise((resolve) => { releaseVisitorData = resolve; }); const mockExecuteBotGuard = mock(async (): Promise => "botguard-response"); -const mockMintPoToken = mock(async (_token: string, id: string): Promise => `pot-${id}`); -const mockResetBotGuardPage = mock(async (): Promise => undefined); +let releaseRacingMint: (() => void) | undefined; +let markRacingMintStarted: (() => void) | undefined; +const refreshEvents: string[] = []; +const mockMintPoToken = mock(async (_token: string, id: string): Promise => { + if (id === "racing-video") { + refreshEvents.push("mint-started"); + markRacingMintStarted?.(); + await new Promise((resolve) => { + releaseRacingMint = resolve; + }); + refreshEvents.push("mint-finished"); + } + return `pot-${id}`; +}); +const mockResetBotGuardPage = mock(async (): Promise => { + refreshEvents.push("page-reset"); +}); let visitorDataRequests = 0; mock.module("../src/botguard-page.ts", () => ({ @@ -85,6 +100,28 @@ describe("token refresh concurrency", () => { expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); }); + it("waits for active video token mints before resetting BotGuard", async () => { + const racingMintStarted = new Promise((resolve) => { + markRacingMintStarted = resolve; + }); + const eventsBefore = refreshEvents.length; + const activeMint = fetchPoToken("racing-video"); + await racingMintStarted; + const forcedRefresh = fetchPoToken("forced-after-race", true); + const joinedRefresh = fetchPoToken("joined-during-refresh"); + await Promise.resolve(); + expect(refreshEvents.slice(eventsBefore)).toEqual(["mint-started"]); + + releaseRacingMint?.(); + await Promise.all([activeMint, forcedRefresh, joinedRefresh]); + + expect(refreshEvents.slice(eventsBefore)).toEqual([ + "mint-started", + "mint-finished", + "page-reset", + ]); + }); + it("bounds the video token cache", async () => { for (let index = 0; index <= 512; index += 1) { await fetchPoToken(`cache-limit-${index}`); From b4e429213e639b0a265842cd7c0bb650ce641d62 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 14 Jul 2026 06:37:09 +0200 Subject: [PATCH 28/28] fix: mint a fresh video token after each rejection --- src/token-service.ts | 13 +--------- tests/token-service-refresh.test.ts | 39 +++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/token-service.ts b/src/token-service.ts index ee5bfef..548b969 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -4,7 +4,6 @@ import { fetchIntegrityToken, fetchVisitorData } from "./innertube.ts"; const EXPIRY_MARGIN_MS = 10 * 60 * 1000; const MAX_CACHED_VIDEO_TOKENS = 512; -const VIDEO_TOKEN_REFRESH_COALESCE_MS = 1000; export type TokenResult = { visitorData: string; @@ -21,7 +20,6 @@ type CachedSession = { expiresAt: number; videoBoundPoTokens: Map; videoBoundPoTokenRequests: Map>; - videoBoundPoTokenRefreshTimes: Map; }; let session: CachedSession | null = null; @@ -53,7 +51,6 @@ async function buildSession(): Promise { expiresAt: Date.now() + ttlMs - refreshMarginMs, videoBoundPoTokens: new Map(), videoBoundPoTokenRequests: new Map(), - videoBoundPoTokenRefreshTimes: new Map(), }; } @@ -70,7 +67,6 @@ async function getVideoBoundPoToken(s: CachedSession, videoId: string): Promise< const oldestVideoId = s.videoBoundPoTokens.keys().next().value; if (oldestVideoId !== undefined) { s.videoBoundPoTokens.delete(oldestVideoId); - s.videoBoundPoTokenRefreshTimes.delete(oldestVideoId); } } s.videoBoundPoTokens.set(videoId, token); @@ -84,15 +80,8 @@ async function getVideoBoundPoToken(s: CachedSession, videoId: string): Promise< async function refreshVideoBoundPoToken(s: CachedSession, videoId: string): Promise { const inFlight = s.videoBoundPoTokenRequests.get(videoId); if (inFlight !== undefined) return inFlight; - const refreshedAt = s.videoBoundPoTokenRefreshTimes.get(videoId); - if (refreshedAt !== undefined && Date.now() - refreshedAt < VIDEO_TOKEN_REFRESH_COALESCE_MS) { - return getVideoBoundPoToken(s, videoId); - } s.videoBoundPoTokens.delete(videoId); - return getVideoBoundPoToken(s, videoId).then((token) => { - s.videoBoundPoTokenRefreshTimes.set(videoId, Date.now()); - return token; - }); + return getVideoBoundPoToken(s, videoId); } function startSessionRefresh(): Promise { diff --git a/tests/token-service-refresh.test.ts b/tests/token-service-refresh.test.ts index a5117bf..42fc485 100644 --- a/tests/token-service-refresh.test.ts +++ b/tests/token-service-refresh.test.ts @@ -13,8 +13,23 @@ const visitorDataGate = new Promise((resolve) => { const mockExecuteBotGuard = mock(async (): Promise => "botguard-response"); let releaseRacingMint: (() => void) | undefined; let markRacingMintStarted: (() => void) | undefined; +let releaseRefreshMint: (() => void) | undefined; +let markRefreshMintStarted: (() => void) | undefined; +let blockRefreshMint = false; +let refreshMintGeneration = 0; const refreshEvents: string[] = []; const mockMintPoToken = mock(async (_token: string, id: string): Promise => { + if (id === "refresh-video") { + const generation = ++refreshMintGeneration; + if (blockRefreshMint) { + blockRefreshMint = false; + markRefreshMintStarted?.(); + await new Promise((resolve) => { + releaseRefreshMint = resolve; + }); + } + return `pot-${id}-${generation}`; + } if (id === "racing-video") { refreshEvents.push("mint-started"); markRacingMintStarted?.(); @@ -89,15 +104,25 @@ describe("token refresh concurrency", () => { }); it("deduplicates concurrent video token refreshes", async () => { - await fetchPoToken("refresh-video"); + const initial = await fetchPoToken("refresh-video"); const callsBefore = mockMintPoToken.mock.calls.length; - await Promise.all([ - fetchPoToken("refresh-video", false, true), - fetchPoToken("refresh-video", false, true), - ]); - expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); - await fetchPoToken("refresh-video", false, true); + const refreshMintStarted = new Promise((resolve) => { + markRefreshMintStarted = resolve; + }); + blockRefreshMint = true; + const firstRefresh = fetchPoToken("refresh-video", false, true); + await refreshMintStarted; + const joinedRefresh = fetchPoToken("refresh-video", false, true); + releaseRefreshMint?.(); + const [firstResult, joinedResult] = await Promise.all([firstRefresh, joinedRefresh]); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); + expect(firstResult.videoBoundPoToken).toBe(joinedResult.videoBoundPoToken); + expect(firstResult.videoBoundPoToken).not.toBe(initial.videoBoundPoToken); + + const sequentialResult = await fetchPoToken("refresh-video", false, true); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 2); + expect(sequentialResult.videoBoundPoToken).not.toBe(firstResult.videoBoundPoToken); }); it("waits for active video token mints before resetting BotGuard", async () => {