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
1 change: 1 addition & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
self-hosted-runner:
labels:
- arko
- r730
- typetype
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ concurrency:

jobs:
quality:
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }}
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }}
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
Expand Down
158 changes: 126 additions & 32 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,20 @@ concurrency:
cancel-in-progress: true

jobs:
build-and-push:
runs-on: [self-hosted, Linux, X64, arko, typetype]
timeout-minutes: 45
permissions:
contents: read
packages: write
prepare:
runs-on: [self-hosted, Linux, X64, r730, typetype]
outputs:
digest: ${{ steps.build.outputs.digest }}
build-time: ${{ steps.build-info.outputs.build-time }}
image: ${{ steps.build-info.outputs.image }}
labels: ${{ steps.meta.outputs.labels }}
metadata-json: ${{ steps.meta.outputs.json }}
version: ${{ steps.build-info.outputs.version }}

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

- name: Isolate Docker credentials
run: echo "DOCKER_CONFIG=$RUNNER_TEMP/docker-config" >> "$GITHUB_ENV"

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
Expand Down Expand Up @@ -92,13 +86,6 @@ jobs:
echo "build-time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
} >> "$GITHUB_OUTPUT"

- name: Log in to GitHub Container Registry
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
Expand All @@ -115,7 +102,39 @@ jobs:
type=raw,value=latest,enable=${{ github.ref_name == 'dev' }}
type=raw,value=beta,enable=${{ github.ref_name == 'dev' }}

build-platform:
needs: prepare
runs-on: [self-hosted, Linux, X64, r730, typetype]
timeout-minutes: 45
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
- platform: linux/arm64
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false

- name: Isolate Docker credentials
run: echo "DOCKER_CONFIG=$RUNNER_TEMP/docker-config" >> "$GITHUB_ENV"

- name: Log in to GitHub Container Registry
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up QEMU
if: matrix.arch == 'arm64'
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8
with:
platforms: arm64
Expand All @@ -124,38 +143,113 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c

- name: Build and push
- name: Build and push platform image
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: .
build-args: |
BUILD_VERSION=${{ steps.build-info.outputs.version }}
BUILD_VERSION=${{ needs.prepare.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 }}
labels: ${{ steps.meta.outputs.labels }}
BUILD_TIME=${{ needs.prepare.outputs.build-time }}
platforms: ${{ matrix.platform }}
labels: ${{ needs.prepare.outputs.labels }}
outputs: type=image,name=${{ needs.prepare.outputs.image }},push-by-digest=true,name-canonical=true,push=true
provenance: mode=max
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=token-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=token-${{ matrix.arch }}

- name: Export digest
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
digest_dir="$RUNNER_TEMP/token-digests"
rm -rf "$digest_dir"
mkdir -p "$digest_dir"
touch "$digest_dir/${DIGEST#sha256:}"

- name: Upload digest
uses: actions/upload-artifact@v7
with:
name: token-digest-${{ matrix.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/token-digests/*
if-no-files-found: error
retention-days: 1

publish:
needs: [prepare, build-platform]
runs-on: [self-hosted, Linux, X64, r730, typetype]
timeout-minutes: 10
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.manifest.outputs.digest }}
steps:
- name: Isolate Docker credentials
run: echo "DOCKER_CONFIG=$RUNNER_TEMP/docker-config" >> "$GITHUB_ENV"

- name: Prepare digest directory
run: rm -rf "$RUNNER_TEMP/token-digests"

- name: Download digests
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/token-digests
pattern: token-digest-*-${{ github.run_id }}-${{ github.run_attempt }}
merge-multiple: true

- name: Log in to GitHub Container Registry
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c

- name: Publish manifest list
id: manifest
env:
DIGEST_DIR: ${{ runner.temp }}/token-digests
IMAGE: ${{ needs.prepare.outputs.image }}
METADATA_JSON: ${{ needs.prepare.outputs.metadata-json }}
run: |
mapfile -t digests < <(find "$DIGEST_DIR" -maxdepth 1 -type f -printf '%f\n' | sort)
if [[ "${#digests[@]}" -ne 2 ]]; then
echo "Expected two platform digests, found ${#digests[@]}"
exit 1
fi
mapfile -t tags < <(jq -r '.tags[]' <<< "$METADATA_JSON")
tag_args=()
for tag in "${tags[@]}"; do tag_args+=(--tag "$tag"); done
source_args=()
for digest in "${digests[@]}"; do source_args+=("$IMAGE@sha256:$digest"); done
docker buildx imagetools create "${tag_args[@]}" "${source_args[@]}"
manifest_json="$(docker buildx imagetools inspect "${tags[0]}" --format '{{json .Manifest}}')"
digest="$(jq -r '.digest' <<< "$manifest_json")"
if [[ "$digest" != sha256:* ]]; then
echo "Published manifest has no valid digest"
exit 1
fi
echo "digest=$digest" >> "$GITHUB_OUTPUT"

notify-orchestrator:
needs: build-and-push
needs: [prepare, publish]
if: github.ref_name == 'dev' || github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/v')
runs-on: [self-hosted, Linux, X64, arko, typetype]
runs-on: [self-hosted, Linux, X64, r730, typetype]
permissions:
contents: read
env:
GH_TOKEN: ${{ secrets.TYPETYPE_ORCHESTRATOR_TOKEN }}
steps:
- name: Send image digest
env:
DIGEST: ${{ needs.build-and-push.outputs.digest }}
IMAGE: ${{ needs.build-and-push.outputs.image }}
VERSION: ${{ needs.build-and-push.outputs.version }}
DIGEST: ${{ needs.publish.outputs.digest }}
IMAGE: ${{ needs.prepare.outputs.image }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
if [[ "$GITHUB_REF_NAME" == "dev" ]]; then channel="beta"; else channel="stable"; fi
jq -n \
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "typetype-token",
"version": "1.7.0",
"version": "1.7.2",
"private": true,
"license": "MIT",
"scripts": {
Expand Down
39 changes: 39 additions & 0 deletions src/youtube-sabr-identity-refresher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { fetchPoToken, type TokenResult } from "./token-service.ts";
import {
getYoutubeInnertube,
invalidateYoutubeInnertube,
type YoutubeInnertube,
} from "./youtube-innertube-session.ts";
import type { YoutubeSabrClient } from "./youtube-sabr-types.ts";

type YoutubeSabrIdentityDependencies<Session> = {
refreshTokens: (videoId: string) => Promise<TokenResult>;
getSession: (client: YoutubeSabrClient, visitorData: string) => Promise<Session>;
invalidateSession: (
client: YoutubeSabrClient,
visitorData: string,
session: Session,
) => Promise<void>;
};

export class YoutubeSabrIdentityRefresher<Session> {
constructor(private readonly dependencies: YoutubeSabrIdentityDependencies<Session>) {}

async refresh(
videoId: string,
client: YoutubeSabrClient,
rejectedVisitorData: string,
rejectedSession: Session,
): Promise<{ tokens: TokenResult; session: Session }> {
await this.dependencies.invalidateSession(client, rejectedVisitorData, rejectedSession);
const tokens = await this.dependencies.refreshTokens(videoId);
const session = await this.dependencies.getSession(client, tokens.visitorData);
return { tokens, session };
}
}

export const youtubeSabrIdentityRefresher = new YoutubeSabrIdentityRefresher<YoutubeInnertube>({
refreshTokens: (videoId) => fetchPoToken(videoId, true),
getSession: getYoutubeInnertube,
invalidateSession: invalidateYoutubeInnertube,
});
14 changes: 10 additions & 4 deletions src/youtube-sabr-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import {
} from "./youtube-channel-avatar-cache.ts";
import {
getYoutubeInnertube,
invalidateYoutubeInnertube,
isRejectedAnonymousSession,
type YoutubeInnertube,
} from "./youtube-innertube-session.ts";
import { withYoutubeClientVersion } from "./youtube-mweb-config.ts";
import { toYoutubeSabrAdaptiveFormat } from "./youtube-sabr-adaptive-format.ts";
import { youtubeSabrIdentityRefresher } from "./youtube-sabr-identity-refresher.ts";
import { buildYoutubeSabrPlayerRequest } from "./youtube-sabr-player-request.ts";
import type { YoutubeSabrClient, YoutubeSabrSession } from "./youtube-sabr-types.ts";

Expand All @@ -37,7 +37,7 @@ async function loadYoutubeSabrSession(
client: YoutubeSabrClient,
reloadPlaybackParamsToken?: string,
): Promise<YoutubeSabrSession> {
const tokens = await fetchPoToken(videoId);
let tokens = await fetchPoToken(videoId);
let innertube = await getYoutubeInnertube(client, tokens.visitorData);
let responses = await fetchYoutubeResponses(
videoId,
Expand All @@ -47,8 +47,14 @@ async function loadYoutubeSabrSession(
);
const playability = responses.videoInfo.playability_status;
if (isRejectedAnonymousSession(playability?.status, playability?.reason)) {
await invalidateYoutubeInnertube(client, tokens.visitorData, innertube);
innertube = await getYoutubeInnertube(client, tokens.visitorData);
const refreshed = await youtubeSabrIdentityRefresher.refresh(
videoId,
client,
tokens.visitorData,
innertube,
);
tokens = refreshed.tokens;
innertube = refreshed.session;
responses = await fetchYoutubeResponses(
videoId,
innertube,
Expand Down
39 changes: 39 additions & 0 deletions tests/youtube-sabr-identity-refresher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import type { TokenResult } from "../src/token-service.ts";
import { YoutubeSabrIdentityRefresher } from "../src/youtube-sabr-identity-refresher.ts";

describe("YouTube SABR identity refresh", () => {
test("invalidates a rejected identity before requesting a fresh session", async () => {
const events: string[] = [];
const tokens = {
visitorData: "new-visitor",
visitorBoundPoToken: "new-player-token",
videoBoundPoToken: "new-media-token",
poToken: "new-player-token",
streamingPot: "new-media-token",
} satisfies TokenResult;
const refresher = new YoutubeSabrIdentityRefresher<string>({
refreshTokens: async (videoId) => {
events.push(`refresh:${videoId}`);
return tokens;
},
getSession: async (client, visitorData) => {
events.push(`open:${client}:${visitorData}`);
return "new-session";
},
invalidateSession: async (client, visitorData, session) => {
events.push(`invalidate:${client}:${visitorData}:${session}`);
},
});

expect(await refresher.refresh("video-id", "MWEB", "old-visitor", "old-session")).toEqual({
tokens,
session: "new-session",
});
expect(events).toEqual([
"invalidate:MWEB:old-visitor:old-session",
"refresh:video-id",
"open:MWEB:new-visitor",
]);
});
});