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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 25 additions & 4 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:

env:
REGISTRY: ghcr.io
UPSTREAM_REPOSITORY: TypeType-Video/TypeType
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

permissions:
Expand All @@ -34,7 +35,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false

Expand All @@ -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
Expand All @@ -68,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 }}
Expand All @@ -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
Expand Down
9 changes: 7 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
ARG BUILD_VERSION=0.1.0
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
Expand Down
2 changes: 1 addition & 1 deletion THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
34 changes: 17 additions & 17 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "typetype-token",
"version": "0.1.0",
"version": "1.3.0",
"private": true,
"license": "MIT",
"scripts": {
Expand All @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion src/build-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 35 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,48 @@ 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";
return Response.json({ error: message }, { status: 500 });
}
}

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<
Expand Down
13 changes: 13 additions & 0 deletions src/youtube-channel-avatar-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CachedAvatar>();

export function getCachedYoutubeChannelAvatar(videoId: string): string | null {
Expand All @@ -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();
}
7 changes: 6 additions & 1 deletion src/youtube-innertube-session.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -46,12 +47,16 @@ export class YoutubeInnertubeSessions<Session> {

const sessions = new YoutubeInnertubeSessions<YoutubeInnertube>(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(
Expand Down
52 changes: 52 additions & 0 deletions src/youtube-mweb-config.ts
Original file line number Diff line number Diff line change
@@ -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<YoutubeMwebConfig> {
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;
}
Loading