From ea060513d874e09a4b615ce4b74d31090bcadf4a Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Sat, 22 Aug 2026 13:34:33 +0100 Subject: [PATCH] fix(lavalink): delegate YouTube deciphering to a yt-cipher sidecar With the TV User-Agent fix in place, playback got past the playability check and died one step later: Client [TVHTML5] failed: Must find sig function from script: /s/player/2574220e/player_embed.vflset/el_GR/base.js The plugin's regex-based extraction cannot find the signature function in the current player script, so a valid OAuth token and an accepted client still yield no stream. Upstream's standing answer to this error is a remote cipher server rather than a plugin fix - the maintainer says so directly in #225 - and the README documents the `remoteCipher` block for exactly this. Adds yt-cipher to compose.yml on the private network, wires `plugins.youtube.remoteCipher` at it, and shares a `YT_CIPHER_PASSWORD` between the two. `OVERRIDE_PLAYER_VARIANT: IAS` is what makes it work: upstream reports only the IAS variant is consistently usable, and `player_embed` - the variant the plugin asks for - is the one that fails. Verified end to end against this repository's own compose.yml and application.yml, with a throwaway password and no OAuth token at all: six unrelated videos (kJQP7kiw5Fk, lp0drugrSM4, 8CFh_-qtzeg, YC-QnlIEbJU, xcFbyuzd9S8, CcNo07Xp8aQ) all reach TrackStart and run without a TrackExceptionEvent, where the same stack without the sidecar fails all six at the signature stage. The six-video set is deliberate: #226 documents that dQw4w9WgXcQ passes even on broken builds, so validating against one video proves nothing. No healthcheck on the service: the image is a compiled Deno binary on distroless, with no shell, curl, nc or deno CLI to run a check with - verified by inspecting the image. Lavalink logs a cipher error per track if it is unreachable, and it is pinned only by the moving :master tag because the project publishes no versioned releases; both are noted in the compose comments. Co-Authored-By: Claude Opus 5 --- .env.example | 7 +++++++ compose.yml | 35 +++++++++++++++++++++++++++++++++++ docs/canonical/operations.md | 29 ++++++++++++++++++++++++++++- lavalink/application.yml | 11 +++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 34b7618..7ba4f50 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,13 @@ SPOTIFY_COUNTRY_CODE=PT # Full procedure: docs/canonical/operations.md. YOUTUBE_OAUTH_REFRESH_TOKEN= +# Shared secret between Lavalink and the yt-cipher sidecar (see compose.yml), which deciphers +# YouTube's signature challenges. Both sides read this same variable, so any value works as +# long as it matches - generate one with `openssl rand -hex 32`. Leaving it empty disables +# auth on the sidecar; that is survivable only because it is never published off the private +# Docker network. It is not a YouTube or Google credential. +YT_CIPHER_PASSWORD= + # Runtime BOT_STATUS_TEXT=música | /play LOG_LEVEL=info diff --git a/compose.yml b/compose.yml index 08b371c..fb09964 100644 --- a/compose.yml +++ b/compose.yml @@ -9,6 +9,14 @@ services: # for the first boot: Lavalink then prints a device-login code to link a burner account. # See docs/canonical/operations.md. YOUTUBE_OAUTH_REFRESH_TOKEN: "${YOUTUBE_OAUTH_REFRESH_TOKEN:-}" + # Shared with the yt-cipher service below. Empty disables auth on it, which is tolerable + # only because it is never published off this private network. + YT_CIPHER_PASSWORD: "${YT_CIPHER_PASSWORD:-}" + depends_on: + # service_started, not service_healthy: yt-cipher's image is distroless (see below). + # Lavalink calls it lazily per track, so a brief head start is all this needs to buy. + yt-cipher: + condition: service_started volumes: - ./lavalink/application.yml:/opt/Lavalink/application.yml:ro healthcheck: @@ -65,6 +73,33 @@ services: networks: - dijay-private + # Deciphers YouTube's signature/n-parameter challenges for Lavalink. Not optional: the + # plugin's own regex-based extraction fails on the current player script + # ("Must find sig function from script: .../player_embed.vflset//base.js"), which + # blocks playback on the very last step even with OAuth working. Upstream's answer to that + # error is this service - see docs/canonical/operations.md. + yt-cipher: + # :master is a moving tag because the project publishes no versioned releases. Pin a + # digest here if reproducible deploys matter more than picking up its fixes automatically. + image: ghcr.io/kikkia/yt-cipher:master + restart: unless-stopped + environment: + API_TOKEN: "${YT_CIPHER_PASSWORD:-}" + # Upstream: only the IAS player variant works consistently, and the variant the plugin + # asks for (player_embed) is exactly the one that fails. Forcing IAS is the fix. + OVERRIDE_PLAYER_VARIANT: IAS + # No healthcheck: the image is a compiled Deno binary on distroless (entrypoint + # /app/server, user nonroot) with no shell, curl, nc or deno CLI to run a check with - + # verified by inspecting the image. Lavalink logs a cipher error per track if it is down. + mem_limit: 1g + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" + networks: + - dijay-private + networks: dijay-private: driver: bridge diff --git a/docs/canonical/operations.md b/docs/canonical/operations.md index a5a981d..7d26143 100644 --- a/docs/canonical/operations.md +++ b/docs/canonical/operations.md @@ -89,7 +89,9 @@ one of two distinct ways — check the Lavalink logs to tell them apart, since t **Resolution fails** (`/play` replies "Não encontrei nenhuma faixa", the bot never joins): Lavalink logs show `ScriptExtractionException: Must find sig function`. YouTube changed its -player script and the plugin's own scraping is outdated. +player script and the plugin's own scraping is outdated. Deciphering is delegated to the +`yt-cipher` sidecar precisely so this class of breakage is absorbed there — see the section +below before reaching for a plugin bump. **Resolution succeeds but playback is silent** (a track title shows, the bot joins, no audio): Lavalink logs show a `TrackExceptionEvent` with something like `This video requires login` or @@ -140,6 +142,31 @@ touched `src/` is not live until: Check `docker compose ps` — the `CREATED` column shows the image age. A bot container created long before the last deploy is running old code. +### The yt-cipher sidecar + +`compose.yml` runs `yt-cipher`, which deciphers YouTube's signature and n-parameter challenges +on Lavalink's behalf (`plugins.youtube.remoteCipher` in `application.yml`). It is not optional. +The plugin's own regex-based extraction fails on the current player script: + +`Must find sig function from script: /s/player//player_embed.vflset//base.js` + +which lands on the very last step of playback — OAuth working, the client accepted, formats +listed, and then no stream. Upstream's standing answer to that error is a remote cipher server +rather than a plugin fix; the maintainer says so directly in +[issue #225](https://github.com/lavalink-devs/youtube-source/issues/225). + +`OVERRIDE_PLAYER_VARIANT: IAS` in `compose.yml` matters: upstream reports only the IAS variant +works consistently, and `player_embed` — the one the plugin asks for — is exactly the one that +fails. Forcing IAS is what makes the failure go away. + +Set `YT_CIPHER_PASSWORD` in `.env` to any value; both sides read the same variable, so it only +has to match. Generate one with `openssl rand -hex 32`. Empty disables auth on the sidecar, +which is survivable only because it is never published off the private Docker network. It is +not a Google credential and has nothing to do with the OAuth token. + +The image has no healthcheck because it is a compiled Deno binary on distroless — no shell, +curl, nc or deno CLI to run one with. If it is down, Lavalink logs a cipher error per track. + ### Completing the YouTube OAuth login `plugins.youtube.oauth` is enabled in `lavalink/application.yml`, but it does nothing until an diff --git a/lavalink/application.yml b/lavalink/application.yml index 11ba37b..11040b2 100644 --- a/lavalink/application.yml +++ b/lavalink/application.yml @@ -82,6 +82,17 @@ plugins: # recreate lavalink again so it survives restarts without redoing the device login. # See https://github.com/lavalink-devs/youtube-source for details, including the # alternative (narrower, WEB/WEBEMBEDDED-only) poToken option. + # Signature/n-parameter deciphering is delegated to the yt-cipher sidecar. The plugin's + # own extraction fails on the current player script ("Must find sig function from script: + # .../player_embed.vflset//base.js"), and upstream's standing answer to that error + # is a remote cipher server rather than a plugin fix - the maintainer says as much in + # https://github.com/lavalink-devs/youtube-source/issues/225. The sidecar is forced to the + # IAS player variant (see compose.yml), which is what makes the embed-script failure go + # away. Without this block, playback dies at the format stage even with OAuth working. + remoteCipher: + url: "http://yt-cipher:8001" + password: "${YT_CIPHER_PASSWORD:}" + userAgent: "DiJay" oauth: enabled: true refreshToken: "${YOUTUBE_OAUTH_REFRESH_TOKEN:}"