From 45f963ccb74d42cb99f5ced15ba25e5d1bb7281f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 00:33:13 +0200 Subject: [PATCH 1/7] fix: align MWEB SABR player requests --- src/youtube-innertube-session.ts | 7 ++- src/youtube-mweb-config.ts | 52 +++++++++++++++++++++++ src/youtube-sabr-player-request.ts | 46 ++++++++++++++++++++ src/youtube-sabr-session.ts | 47 +++++++++++++------- tests/youtube-mweb-config.test.ts | 49 +++++++++++++++++++++ tests/youtube-sabr-player-request.test.ts | 31 ++++++++++++++ 6 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 src/youtube-mweb-config.ts create mode 100644 src/youtube-sabr-player-request.ts create mode 100644 tests/youtube-mweb-config.test.ts create mode 100644 tests/youtube-sabr-player-request.test.ts diff --git a/src/youtube-innertube-session.ts b/src/youtube-innertube-session.ts index fbc25a1..70186f8 100644 --- a/src/youtube-innertube-session.ts +++ b/src/youtube-innertube-session.ts @@ -1,5 +1,6 @@ import Innertube, { ClientType, UniversalCache } from "youtubei.js"; import { youtubeFetch } from "./youtube-fetch.ts"; +import { fetchYoutubeMwebConfig } from "./youtube-mweb-config.ts"; import { installYoutubePlayerEvaluator } from "./youtube-player-evaluator.ts"; import type { YoutubeSabrClient } from "./youtube-sabr-types.ts"; @@ -46,12 +47,16 @@ export class YoutubeInnertubeSessions { const sessions = new YoutubeInnertubeSessions(async (client, visitorData) => { installYoutubePlayerEvaluator(); - return Innertube.create({ + const innertube = await Innertube.create({ cache: new UniversalCache(true), client_type: client === "MWEB" ? ClientType.MWEB : ClientType.WEB, fetch: youtubeFetch, visitor_data: visitorData, }); + if (client === "MWEB") { + innertube.session.context.client.clientVersion = (await fetchYoutubeMwebConfig()).clientVersion; + } + return innertube; }); export async function getYoutubeInnertube( diff --git a/src/youtube-mweb-config.ts b/src/youtube-mweb-config.ts new file mode 100644 index 0000000..0232e9e --- /dev/null +++ b/src/youtube-mweb-config.ts @@ -0,0 +1,52 @@ +export type YoutubeMwebConfig = { + clientVersion: string; +}; + +const MWEB_CONFIG_URL = "https://m.youtube.com/sw.js_data"; +const MWEB_REFERER = "https://m.youtube.com/sw.js"; +const MWEB_USER_AGENT = + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36"; + +export async function fetchYoutubeMwebConfig( + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher(MWEB_CONFIG_URL, { + headers: { + Accept: "*/*", + "Accept-Language": "en-US", + Referer: MWEB_REFERER, + "User-Agent": MWEB_USER_AGENT, + }, + }); + if (!response.ok) { + throw new Error(`YouTube MWEB config returned ${response.status}`); + } + return parseYoutubeMwebConfig(await response.text()); +} + +export function parseYoutubeMwebConfig(body: string): YoutubeMwebConfig { + const payload = body.replace(/^\)\]\}'\s*/, ""); + const root = asArray(JSON.parse(payload), "root"); + const serviceData = asArray(root[0], "service data"); + const configurations = asArray(serviceData[2], "configurations"); + const configuration = asArray(configurations[0], "configuration"); + const deviceInfo = asArray(configuration[0], "device info"); + const clientVersion = deviceInfo[16]; + if (typeof clientVersion !== "string" || !/^\d+\.\d+\.\d+\.\d+$/.test(clientVersion)) { + throw new Error("YouTube MWEB client version is missing"); + } + return { clientVersion }; +} + +export function withYoutubeClientVersion(url: string, clientVersion: string): string { + const parsed = new URL(url); + parsed.searchParams.set("cver", clientVersion); + return parsed.toString(); +} + +function asArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`YouTube MWEB ${label} is invalid`); + } + return value; +} diff --git a/src/youtube-sabr-player-request.ts b/src/youtube-sabr-player-request.ts new file mode 100644 index 0000000..2ebd1aa --- /dev/null +++ b/src/youtube-sabr-player-request.ts @@ -0,0 +1,46 @@ +export function buildYoutubeSabrPlaybackContext( + signatureTimestamp: number | undefined, + reloadPlaybackParamsToken?: string, +) { + const playbackContext: { + adPlaybackContext: { pyv: boolean }; + contentPlaybackContext: { + vis: number; + splay: boolean; + lactMilliseconds: string; + signatureTimestamp: number | undefined; + }; + reloadPlaybackContext?: { + reloadPlaybackParams: { token: string }; + }; + } = { + adPlaybackContext: { pyv: true }, + contentPlaybackContext: { + vis: 0, + splay: false, + lactMilliseconds: "-1", + signatureTimestamp, + }, + }; + if (reloadPlaybackParamsToken) { + playbackContext.reloadPlaybackContext = { + reloadPlaybackParams: { token: reloadPlaybackParamsToken }, + }; + } + + return playbackContext; +} + +export function buildYoutubeSabrPlayerRequest( + signatureTimestamp: number | undefined, + playerPoToken: string, + reloadPlaybackParamsToken?: string, +) { + return { + playbackContext: buildYoutubeSabrPlaybackContext(signatureTimestamp, reloadPlaybackParamsToken), + serviceIntegrityDimensions: { poToken: playerPoToken }, + attestationRequest: { omitBotguardData: true }, + contentCheckOk: true, + racyCheckOk: true, + }; +} diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index 8e00290..df938bf 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -13,7 +13,9 @@ import { isRejectedAnonymousSession, type YoutubeInnertube, } from "./youtube-innertube-session.ts"; +import { withYoutubeClientVersion } from "./youtube-mweb-config.ts"; import { toYoutubeSabrAdaptiveFormat } from "./youtube-sabr-adaptive-format.ts"; +import { buildYoutubeSabrPlayerRequest } from "./youtube-sabr-player-request.ts"; import type { YoutubeSabrClient, YoutubeSabrSession } from "./youtube-sabr-types.ts"; const sessionRequests = new KeyedSingleFlight(); @@ -21,22 +23,37 @@ const sessionRequests = new KeyedSingleFlight(); export async function fetchYoutubeSabrSession( videoId: string, client: YoutubeSabrClient = "MWEB", + reloadPlaybackParamsToken?: string, ): Promise { + if (reloadPlaybackParamsToken) { + return loadYoutubeSabrSession(videoId, client, reloadPlaybackParamsToken); + } return sessionRequests.run(`${client}:${videoId}`, () => loadYoutubeSabrSession(videoId, client)); } async function loadYoutubeSabrSession( videoId: string, client: YoutubeSabrClient, + reloadPlaybackParamsToken?: string, ): Promise { const tokens = await fetchPoToken(videoId); let innertube = await getYoutubeInnertube(client, tokens.visitorData); - let responses = await fetchYoutubeResponses(videoId, innertube, tokens.visitorBoundPoToken); + let responses = await fetchYoutubeResponses( + videoId, + innertube, + tokens.visitorBoundPoToken, + reloadPlaybackParamsToken, + ); const playability = responses.videoInfo.playability_status; if (isRejectedAnonymousSession(playability?.status, playability?.reason)) { await invalidateYoutubeInnertube(client, tokens.visitorData, innertube); innertube = await getYoutubeInnertube(client, tokens.visitorData); - responses = await fetchYoutubeResponses(videoId, innertube, tokens.visitorBoundPoToken); + responses = await fetchYoutubeResponses( + videoId, + innertube, + tokens.visitorBoundPoToken, + reloadPlaybackParamsToken, + ); } const { videoInfo, nextResponse } = responses; if (videoInfo.playability_status?.status !== "OK") { @@ -56,24 +73,18 @@ async function fetchYoutubeResponses( videoId: string, innertube: YoutubeInnertube, poToken: string, + reloadPlaybackParamsToken?: string, ) { const endpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } }); const nextEndpoint = new YTNodes.NavigationEndpoint({ watchNextEndpoint: { videoId } }); const cachedChannelAvatarUrl = getCachedYoutubeChannelAvatar(videoId); const [videoInfo, nextResponse] = await Promise.all([ endpoint.call(innertube.actions, { - playbackContext: { - adPlaybackContext: { pyv: true }, - contentPlaybackContext: { - vis: 0, - splay: false, - lactMilliseconds: "-1", - signatureTimestamp: innertube.session.player?.signature_timestamp, - }, - }, - serviceIntegrityDimensions: { poToken }, - contentCheckOk: true, - racyCheckOk: true, + ...buildYoutubeSabrPlayerRequest( + innertube.session.player?.signature_timestamp, + poToken, + reloadPlaybackParamsToken, + ), parse: true, }), cachedChannelAvatarUrl @@ -91,9 +102,15 @@ async function buildYoutubeSabrSession( videoInfo: Awaited>, channelAvatarUrl: string, ): Promise { - const serverAbrStreamingUrl = await innertube.session.player?.decipher( + const decipheredServerAbrStreamingUrl = await innertube.session.player?.decipher( videoInfo.streaming_data?.server_abr_streaming_url, ); + const serverAbrStreamingUrl = decipheredServerAbrStreamingUrl + ? withYoutubeClientVersion( + decipheredServerAbrStreamingUrl, + innertube.session.context.client.clientVersion, + ) + : undefined; const videoPlaybackUstreamerConfig = videoInfo.player_config?.media_common_config.media_ustreamer_request_config ?.video_playback_ustreamer_config; diff --git a/tests/youtube-mweb-config.test.ts b/tests/youtube-mweb-config.test.ts new file mode 100644 index 0000000..774d85c --- /dev/null +++ b/tests/youtube-mweb-config.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { + fetchYoutubeMwebConfig, + parseYoutubeMwebConfig, + withYoutubeClientVersion, +} from "../src/youtube-mweb-config.ts"; + +const configBody = (clientVersion: string) => { + const deviceInfo = Array.from({ length: 17 }, () => null); + deviceInfo[16] = clientVersion; + return `)]}'\n\n${JSON.stringify([["yt.sw.adr", null, [[deviceInfo], "api-key"]]])}`; +}; + +describe("YouTube MWEB config", () => { + it("reads the current client version from structured service worker data", () => { + expect(parseYoutubeMwebConfig(configBody("2.20260723.05.00"))).toEqual({ + clientVersion: "2.20260723.05.00", + }); + }); + + it("rejects malformed service worker data", () => { + expect(() => parseYoutubeMwebConfig(`)]}'\n\n[]`)).toThrow( + "YouTube MWEB service data is invalid", + ); + }); + + it("fetches the MWEB config with the mobile origin", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fetcher = async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response(configBody("2.20260723.05.00")); + }; + + await expect(fetchYoutubeMwebConfig(fetcher)).resolves.toEqual({ + clientVersion: "2.20260723.05.00", + }); + expect(calls[0]?.url).toBe("https://m.youtube.com/sw.js_data"); + expect(new Headers(calls[0]?.init?.headers).get("referer")).toBe("https://m.youtube.com/sw.js"); + }); + + it("keeps the SABR URL aligned with the player client version", () => { + expect( + withYoutubeClientVersion( + "https://example.com/videoplayback?c=MWEB&cver=old&cpn=session", + "2.20260723.05.00", + ), + ).toBe("https://example.com/videoplayback?c=MWEB&cver=2.20260723.05.00&cpn=session"); + }); +}); diff --git a/tests/youtube-sabr-player-request.test.ts b/tests/youtube-sabr-player-request.test.ts new file mode 100644 index 0000000..831557f --- /dev/null +++ b/tests/youtube-sabr-player-request.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { + buildYoutubeSabrPlaybackContext, + buildYoutubeSabrPlayerRequest, +} from "../src/youtube-sabr-player-request.ts"; + +describe("buildYoutubeSabrPlaybackContext", () => { + it("nests reload params like the MWEB player request", () => { + const context = buildYoutubeSabrPlaybackContext(123, "reload-token"); + + expect(context.reloadPlaybackContext).toEqual({ + reloadPlaybackParams: { token: "reload-token" }, + }); + }); + + it("omits reload context for initial player requests", () => { + const context = buildYoutubeSabrPlaybackContext(123); + + expect(context.reloadPlaybackContext).toBeUndefined(); + }); + + it("keeps the player and media token contracts separate", () => { + const request = buildYoutubeSabrPlayerRequest(123, "visitor-bound", "reload-token"); + + expect(request.serviceIntegrityDimensions.poToken).toBe("visitor-bound"); + expect(request.attestationRequest).toEqual({ omitBotguardData: true }); + expect(request.playbackContext.reloadPlaybackContext).toEqual({ + reloadPlaybackParams: { token: "reload-token" }, + }); + }); +}); From 321cd75c746f99675b90ab0a81c2680025126261 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 00:33:20 +0200 Subject: [PATCH 2/7] feat: expose SABR player reload route --- src/index.ts | 29 ++++++++++++++ tests/index.test.ts | 97 +++++++++++++++++++++++++++++++-------------- 2 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9de1ad1..e305792 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,35 @@ export async function handler( } } + if (req.method === "POST" && url.pathname === "/youtube/sabr/session/reload") { + try { + const body = (await req.json().catch(() => null)) as { + videoId?: unknown; + client?: unknown; + reloadPlaybackParams?: unknown; + } | null; + const videoId = typeof body?.videoId === "string" ? body.videoId.trim() : ""; + const client = body?.client ?? "MWEB"; + const reloadPlaybackParams = + typeof body?.reloadPlaybackParams === "string" ? body.reloadPlaybackParams.trim() : ""; + if (!videoId || videoId.length > 128) { + return Response.json({ error: "videoId is required" }, { status: 400 }); + } + if (client !== "WEB" && client !== "MWEB") { + return Response.json({ error: "client must be WEB or MWEB" }, { status: 400 }); + } + if (!reloadPlaybackParams || reloadPlaybackParams.length > 8192) { + return Response.json({ error: "reloadPlaybackParams is required" }, { status: 400 }); + } + return Response.json( + await fetchYoutubeSabrSession(videoId, client as YoutubeSabrClient, reloadPlaybackParams), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + return Response.json({ error: message }, { status: 500 }); + } + } + if (req.method === "POST" && url.pathname === "/youtube/player/decoder") { try { const body = (await req.json().catch(() => ({}))) as Parameters< diff --git a/tests/index.test.ts b/tests/index.test.ts index 36ca184..c62ca65 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -14,6 +14,40 @@ const mockFetchPoToken = mock( streamingPot: `video-bound-${videoId}`, }), ); +const mockFetchYoutubeSabrSession = mock( + async ( + videoId: string, + client = "MWEB", + _reloadPlaybackParams?: string, + ): Promise => ({ + videoId, + client: client === "WEB" ? "WEB" : "MWEB", + visitorData: "visitor-data", + 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, + 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: [], + }), +); mock.module("../src/token-service.ts", () => ({ fetchPoToken: mockFetchPoToken, @@ -27,36 +61,7 @@ mock.module("../src/innertube.ts", () => ({ })); 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", - rawServerAbrStreamingUrl: "https://example.test/raw-sabr", - hlsManifestUrl: "https://example.test/live.m3u8", - 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: [], - }), - ), + fetchYoutubeSabrSession: mockFetchYoutubeSabrSession, })); mock.module("../src/youtube-player-decoder.ts", () => ({ @@ -183,6 +188,38 @@ describe("handler", () => { expect(body.videoPlaybackUstreamerConfig).toBe("ustreamer-config"); }); + it("POST /youtube/sabr/session/reload keeps the token out of the URL", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler( + new Request("http://localhost:8081/youtube/sabr/session/reload", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + videoId: "abc", + client: "MWEB", + reloadPlaybackParams: "reload-secret", + }), + }), + ); + + expect(res.status).toBe(200); + expect(mockFetchYoutubeSabrSession.mock.calls.at(-1)).toEqual(["abc", "MWEB", "reload-secret"]); + expect(res.url).not.toContain("reload-secret"); + }); + + it("POST /youtube/sabr/session/reload rejects a missing reload token", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler( + new Request("http://localhost:8081/youtube/sabr/session/reload", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ videoId: "abc" }), + }), + ); + + expect(res.status).toBe(400); + }); + it("POST /youtube/player/decoder returns player decode result", async () => { const { handler } = await import("../src/index.ts"); const res = await handler( From ea2d0c525b9ddebb721f56875a634dc7a02c225b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 29 Jul 2026 05:26:56 +0200 Subject: [PATCH 3/7] fix: isolate SABR download player sessions --- src/index.ts | 7 ++++++- src/youtube-sabr-session.ts | 3 ++- tests/index.test.ts | 11 +++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index e305792..62fb951 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,7 +74,12 @@ export async function handler( } try { - const result = await fetchYoutubeSabrSession(videoId, clientParam as YoutubeSabrClient); + const result = await fetchYoutubeSabrSession( + videoId, + clientParam as YoutubeSabrClient, + undefined, + url.searchParams.get("isolated") === "true", + ); return Response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Internal error"; diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index df938bf..08d6fe0 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -24,8 +24,9 @@ export async function fetchYoutubeSabrSession( videoId: string, client: YoutubeSabrClient = "MWEB", reloadPlaybackParamsToken?: string, + isolated = false, ): Promise { - if (reloadPlaybackParamsToken) { + if (reloadPlaybackParamsToken || isolated) { return loadYoutubeSabrSession(videoId, client, reloadPlaybackParamsToken); } return sessionRequests.run(`${client}:${videoId}`, () => loadYoutubeSabrSession(videoId, client)); diff --git a/tests/index.test.ts b/tests/index.test.ts index c62ca65..45e380f 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -19,6 +19,7 @@ const mockFetchYoutubeSabrSession = mock( videoId: string, client = "MWEB", _reloadPlaybackParams?: string, + _isolated = false, ): Promise => ({ videoId, client: client === "WEB" ? "WEB" : "MWEB", @@ -188,6 +189,16 @@ describe("handler", () => { expect(body.videoPlaybackUstreamerConfig).toBe("ustreamer-config"); }); + it("GET /youtube/sabr/session forwards isolated requests", async () => { + const { handler } = await import("../src/index.ts"); + const res = await handler( + new Request("http://localhost:8081/youtube/sabr/session?videoId=abc&isolated=true"), + ); + + expect(res.status).toBe(200); + expect(mockFetchYoutubeSabrSession.mock.calls.at(-1)).toEqual(["abc", "MWEB", undefined, true]); + }); + it("POST /youtube/sabr/session/reload keeps the token out of the URL", async () => { const { handler } = await import("../src/index.ts"); const res = await handler( From 6b332b10ba0e6206aa9ee427cc6803ceda7c38cd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 29 Jul 2026 05:27:03 +0200 Subject: [PATCH 4/7] chore: align Token image version with TypeType --- .github/workflows/docker.yml | 25 +++++++++++++++++++++++-- Dockerfile | 2 +- package.json | 2 +- src/build-info.ts | 2 +- tests/index.test.ts | 2 +- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0ab1fb2..7a05865 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,6 +11,7 @@ on: env: REGISTRY: ghcr.io + UPSTREAM_REPOSITORY: TypeType-Video/TypeType FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true permissions: @@ -48,11 +49,30 @@ jobs: - name: Resolve build metadata id: build-info + env: + GH_TOKEN: ${{ github.token }} run: | - base_version="$(bun -e 'console.log(require("./package.json").version)')" if [[ "$GITHUB_REF" == refs/tags/v* ]]; then - version="${GITHUB_REF_NAME#v}" + base_version="${GITHUB_REF_NAME#v}" + if [[ ! "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid component release tag: $GITHUB_REF_NAME" + exit 1 + fi + gh api "repos/${UPSTREAM_REPOSITORY}/git/ref/tags/v${base_version}" >/dev/null + version="$base_version" else + upstream_tag="$(gh api "repos/${UPSTREAM_REPOSITORY}/releases/latest" --jq '.tag_name')" + if [[ ! "$upstream_tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "Invalid upstream TypeType release tag: $upstream_tag" + exit 1 + fi + base_version="${BASH_REMATCH[1]}" + fi + if [[ "$GITHUB_REF_NAME" == "main" ]]; then + version="$base_version" + elif [[ "$GITHUB_REF_NAME" == "dev" ]]; then + version="$base_version-dev.$GITHUB_RUN_NUMBER" + elif [[ "$GITHUB_REF" != refs/tags/v* ]]; then channel="${GITHUB_REF_NAME//[^0-9A-Za-z-]/-}" version="$base_version-$channel.$GITHUB_RUN_NUMBER" fi @@ -80,6 +100,7 @@ jobs: with: images: ${{ steps.build-info.outputs.image }} tags: | + type=raw,value=${{ steps.build-info.outputs.version }} type=sha,prefix=sha-,format=short type=ref,event=branch type=ref,event=tag diff --git a/Dockerfile b/Dockerfile index 09a9526..eed9a2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ 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_VERSION=1.2.4-dev ARG BUILD_REVISION=development ARG BUILD_TIME=unknown WORKDIR /app diff --git a/package.json b/package.json index 98428d8..cccf6f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "0.1.0", + "version": "1.2.4", "private": true, "license": "MIT", "scripts": { diff --git a/src/build-info.ts b/src/build-info.ts index bd6b1a5..2acda10 100644 --- a/src/build-info.ts +++ b/src/build-info.ts @@ -2,7 +2,7 @@ const revision = process.env.TYPE_TYPE_BUILD_REVISION ?? "development"; export const buildInfo = { service: "token", - version: process.env.TYPE_TYPE_BUILD_VERSION ?? "0.1.0", + version: process.env.TYPE_TYPE_BUILD_VERSION ?? "1.2.4-dev", revision, shortRevision: revision.slice(0, 12), buildTime: process.env.TYPE_TYPE_BUILD_TIME ?? "unknown", diff --git a/tests/index.test.ts b/tests/index.test.ts index 45e380f..ed51008 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -91,7 +91,7 @@ describe("handler", () => { 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" }); + expect(await res.json()).toMatchObject({ service: "token", version: "1.2.4-dev" }); }); it("GET /potoken without videoId returns 400", async () => { From 77887fe4e078b71dd4695e64eb11c1ca05405046 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 29 Jul 2026 15:43:54 +0200 Subject: [PATCH 5/7] perf: bound the channel avatar cache --- src/youtube-channel-avatar-cache.ts | 13 +++++++++++++ tests/youtube-channel-avatar-cache.test.ts | 20 +++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/youtube-channel-avatar-cache.ts b/src/youtube-channel-avatar-cache.ts index 50dc95d..ce39392 100644 --- a/src/youtube-channel-avatar-cache.ts +++ b/src/youtube-channel-avatar-cache.ts @@ -4,6 +4,7 @@ type CachedAvatar = { }; const AVATAR_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const MAX_AVATAR_CACHE_ENTRIES = 1024; const avatarCache = new Map(); export function getCachedYoutubeChannelAvatar(videoId: string): string | null { @@ -13,10 +14,22 @@ export function getCachedYoutubeChannelAvatar(videoId: string): string | null { avatarCache.delete(videoId); return null; } + avatarCache.delete(videoId); + avatarCache.set(videoId, cached); return cached.url; } export function cacheYoutubeChannelAvatar(videoId: string, url: string): void { if (!url) return; + avatarCache.delete(videoId); + while (avatarCache.size >= MAX_AVATAR_CACHE_ENTRIES) { + const oldestVideoId = avatarCache.keys().next().value; + if (oldestVideoId === undefined) break; + avatarCache.delete(oldestVideoId); + } avatarCache.set(videoId, { url, expiresAt: Date.now() + AVATAR_CACHE_TTL_MS }); } + +export function clearYoutubeChannelAvatarCache(): void { + avatarCache.clear(); +} diff --git a/tests/youtube-channel-avatar-cache.test.ts b/tests/youtube-channel-avatar-cache.test.ts index b886e0e..495f013 100644 --- a/tests/youtube-channel-avatar-cache.test.ts +++ b/tests/youtube-channel-avatar-cache.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it } from "bun:test"; +import { beforeEach, describe, expect, it } from "bun:test"; import { cacheYoutubeChannelAvatar, + clearYoutubeChannelAvatarCache, getCachedYoutubeChannelAvatar, } from "../src/youtube-channel-avatar-cache.ts"; describe("youtube channel avatar cache", () => { + beforeEach(() => { + clearYoutubeChannelAvatarCache(); + }); + it("stores non-empty avatar URLs by video", () => { cacheYoutubeChannelAvatar("video-one", "https://example.test/avatar.jpg"); @@ -17,4 +22,17 @@ describe("youtube channel avatar cache", () => { expect(getCachedYoutubeChannelAvatar("video-empty")).toBeNull(); }); + + it("evicts the least recently used avatar at capacity", () => { + for (let index = 0; index < 1024; index++) { + cacheYoutubeChannelAvatar(`video-${index}`, `https://example.test/${index}.jpg`); + } + expect(getCachedYoutubeChannelAvatar("video-0")).toBe("https://example.test/0.jpg"); + + cacheYoutubeChannelAvatar("video-new", "https://example.test/new.jpg"); + + expect(getCachedYoutubeChannelAvatar("video-1")).toBeNull(); + expect(getCachedYoutubeChannelAvatar("video-0")).toBe("https://example.test/0.jpg"); + expect(getCachedYoutubeChannelAvatar("video-new")).toBe("https://example.test/new.jpg"); + }); }); From 144450d8b0f8c5900c6a2b5f5de79826b30cb4ad Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 29 Jul 2026 15:43:54 +0200 Subject: [PATCH 6/7] chore: refresh token runtime --- .github/workflows/ci.yml | 2 +- .github/workflows/docker.yml | 4 ++-- Dockerfile | 7 ++++++- THIRD_PARTY_NOTICES.md | 2 +- bun.lock | 34 +++++++++++++++++----------------- package.json | 8 ++++---- 6 files changed, 31 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 187589a..28abd13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} timeout-minutes: 15 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7a05865..345aecb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false @@ -88,7 +88,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/Dockerfile b/Dockerfile index eed9a2d..bd0e16a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,15 @@ COPY package.json bun.lock ./ ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 RUN bun install --frozen-lockfile --production -FROM mcr.microsoft.com/playwright:v1.61.1-noble AS runner +FROM mcr.microsoft.com/playwright:v1.62.0-noble AS runner ARG BUILD_VERSION=1.2.4-dev ARG BUILD_REVISION=development ARG BUILD_TIME=unknown +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ + && apt-get purge -y gstreamer1.0-plugins-bad libgstreamer-plugins-bad1.0-0 \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* /usr/lib/node_modules 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 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index de5af5f..641bf7a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,5 +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.61.1-noble`. +The Docker runtime image uses `mcr.microsoft.com/playwright:v1.62.0-noble`. The Apache License 2.0 text is included in `LICENSES/Apache-2.0.txt`. diff --git a/bun.lock b/bun.lock index 4c2a20e..0b795bf 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,13 @@ "": { "name": "typetype-token", "dependencies": { - "bgutils-js": "^3.2.0", - "googlevideo": "^4.0.4", - "playwright": "^1.61.1", + "bgutils-js": "^4.0.2", + "googlevideo": "^4.1.1", + "playwright": "^1.62.0", "youtubei.js": "^17.2.0", }, "devDependencies": { - "@biomejs/biome": "^2.5.3", + "@biomejs/biome": "^2.5.6", "bun-types": "^1.3.14", }, }, @@ -20,29 +20,29 @@ "@biomejs/biome", ], "packages": { - "@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/biome": ["@biomejs/biome@2.5.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.6", "@biomejs/cli-darwin-x64": "2.5.6", "@biomejs/cli-linux-arm64": "2.5.6", "@biomejs/cli-linux-arm64-musl": "2.5.6", "@biomejs/cli-linux-x64": "2.5.6", "@biomejs/cli-linux-x64-musl": "2.5.6", "@biomejs/cli-win32-arm64": "2.5.6", "@biomejs/cli-win32-x64": "2.5.6" }, "bin": { "biome": "bin/biome" } }, "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA=="], - "@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": ["@biomejs/cli-linux-arm64@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ=="], "@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=="], + "bgutils-js": ["bgutils-js@4.0.2", "", {}, "sha512-PyZRxSkwC2MyVtIidD0DejgeNQfb9p3N/wY/d9sa3zAFeMTg6nDMv99wPYoUMU8JhzgG22ZVTnCzAcbfzKPVfQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -50,13 +50,13 @@ "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=="], + "googlevideo": ["googlevideo@4.1.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-14o64O+CXXlQ1stYZK/+ypCEwZb25lzEuzhbFYYEzf26R+HhBDOR+Dkdk7MKkZ3qXeIjvZfeC0AJlVKjwQCFjw=="], "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], - "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": ["playwright@1.62.0", "", { "dependencies": { "playwright-core": "1.62.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw=="], - "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "playwright-core": ["playwright-core@1.62.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index cccf6f7..01017e4 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "format": "biome format --write src tests" }, "dependencies": { - "bgutils-js": "^3.2.0", - "googlevideo": "^4.0.4", - "playwright": "^1.61.1", + "bgutils-js": "^4.0.2", + "googlevideo": "^4.1.1", + "playwright": "^1.62.0", "youtubei.js": "^17.2.0" }, "devDependencies": { - "@biomejs/biome": "^2.5.3", + "@biomejs/biome": "^2.5.6", "bun-types": "^1.3.14" }, "trustedDependencies": [ From 4d0452a000a78d4d11ff4a2b863cbf5480276e80 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 09:42:00 +0200 Subject: [PATCH 7/7] chore: prepare token 1.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 01017e4..95152fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.2.4", + "version": "1.3.0", "private": true, "license": "MIT", "scripts": {