diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 306a00e..026649b 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,4 +1,5 @@ self-hosted-runner: labels: - arko + - r730 - typetype diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee4d76c..3ae41ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 400b58b..24f6f41 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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: @@ -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 @@ -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 @@ -124,28 +143,103 @@ 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: @@ -153,9 +247,9 @@ jobs: 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 \ diff --git a/package.json b/package.json index f2851b8..075ddf6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.7.0", + "version": "1.7.2", "private": true, "license": "MIT", "scripts": { diff --git a/src/youtube-sabr-identity-refresher.ts b/src/youtube-sabr-identity-refresher.ts new file mode 100644 index 0000000..66272fb --- /dev/null +++ b/src/youtube-sabr-identity-refresher.ts @@ -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 = { + refreshTokens: (videoId: string) => Promise; + getSession: (client: YoutubeSabrClient, visitorData: string) => Promise; + invalidateSession: ( + client: YoutubeSabrClient, + visitorData: string, + session: Session, + ) => Promise; +}; + +export class YoutubeSabrIdentityRefresher { + constructor(private readonly dependencies: YoutubeSabrIdentityDependencies) {} + + 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({ + refreshTokens: (videoId) => fetchPoToken(videoId, true), + getSession: getYoutubeInnertube, + invalidateSession: invalidateYoutubeInnertube, +}); diff --git a/src/youtube-sabr-session.ts b/src/youtube-sabr-session.ts index b7552bf..d640a68 100644 --- a/src/youtube-sabr-session.ts +++ b/src/youtube-sabr-session.ts @@ -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"; @@ -37,7 +37,7 @@ async function loadYoutubeSabrSession( client: YoutubeSabrClient, reloadPlaybackParamsToken?: string, ): Promise { - const tokens = await fetchPoToken(videoId); + let tokens = await fetchPoToken(videoId); let innertube = await getYoutubeInnertube(client, tokens.visitorData); let responses = await fetchYoutubeResponses( videoId, @@ -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, diff --git a/tests/youtube-sabr-identity-refresher.test.ts b/tests/youtube-sabr-identity-refresher.test.ts new file mode 100644 index 0000000..0363c51 --- /dev/null +++ b/tests/youtube-sabr-identity-refresher.test.ts @@ -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({ + 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", + ]); + }); +});