Add support for SABR (v2) - #5814
Conversation
docs/OWNTUBE-UPSTREAM-PLAN.md proposes replacing Invidious with a service we own, built on youtubei.js. Two pressures point the same way: the fork is now 8 patches rebased forever, and upstream is moving to SABR client-side only (iv-org/invidious#5814), which is a viable answer for a web player but not obviously one for a client set that includes expo-video on Android TV. The plan records what was measured rather than assumed — youtubei.js' coverage (FreeTube's 2,270-line local.js, shipped to 21.5k users), how much of Invidious we actually use (8 endpoint families; ~40% of it is UI and accounts), and that server-side SABR is feasible because googlevideo exports SabrStream for headless use. It also records four corrections to my own earlier reasoning, including that Android TV does *not* structurally force a server-side connector — Materialious ships SABR on Android TV through a Capacitor WebView, so that constraint is our choice of expo-video, not the platform. The detector is the part worth having today. `sabrExposure` measures the share of *non-live* videos that no longer carry init/index byte ranges — the thing that would silently break /dash and /hls. Currently 0/12. Live streams are excluded because they are legitimately segment-addressed; that exclusion leans on liveNow being correct, which Phase 3.2 fixed and listLiveFlag guards. It samples `popular` rather than trending, which was the first attempt and SKIPped: trending is the livestreams feed now, so it yields no non-live sample at all. Canary is 8 PASS + 1 SKIP. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds frontend SABR (v2) playback support (Shaka + SABR scheme/manifest) and adjusts backend parsing/error-handling so watch/embed pages can still render using /next-only metadata when the /player endpoint is unavailable (e.g., no Invidious Companion).
Changes:
- Add a SABR playback mode, including a new
/proxyroute and a new SABR player path in the watch/embed templates. - Extend user/video preferences to support
quality=sabrand a SABR codec preference. - Make video parsing more resilient by falling back to
/nextdata and surfacing improved unplayable error info.
Reviewed changes
Copilot reviewed 39 out of 46 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/invidious/yt_backend/youtube_api.cr | Clarifies player returning nil without Companion to enable /next fallback. |
| src/invidious/yt_backend/extractors_utils.cr | Adds helper to infer unlisted status from badges when videoDetails is missing. |
| src/invidious/views/watch.ecr | Hides player when video.reason is present; surfaces reason/subreason UI. |
| src/invidious/views/user/preferences.ecr | Adds SABR quality option and codec preference UI. |
| src/invidious/views/embed.ecr | Shows unplayable reason/subreason instead of player when unavailable. |
| src/invidious/views/components/player.ecr | Switches between video.js and SABR player, and loads SABR assets when enabled. |
| src/invidious/videos/video_preferences.cr | Adds quality_sabr handling to processed per-video params. |
| src/invidious/videos/parser.cr | Adds /next fallback parsing and enriches error handling (reason/subreason). |
| src/invidious/videos.cr | Bumps schema version and adds subreason accessor + safer reason accessor. |
| src/invidious/user/preferences.cr | Adds persisted preference for quality_sabr. |
| src/invidious/routing.cr | Registers new proxy routes. |
| src/invidious/routes/watch.cr | Avoids redirects/raw playback when the video is in an error state. |
| src/invidious/routes/proxy.cr | Introduces SABR-oriented proxy endpoint for browser SABR traffic. |
| src/invidious/routes/preferences.cr | Persists quality_sabr from preferences form submissions. |
| src/invidious/routes/before_all.cr | Adjusts CSP to allow SABR/BotGuard dependencies and network calls. |
| src/invidious/config.cr | Adds default config preference for quality_sabr. |
| spec/invidious/videos/regular_videos_extract_spec.cr | Adds coverage for parsing without videoDetails and for subreason null behavior. |
| scripts/fetch-sabr-dependencies.cr | Fetches SABR-related third-party browser artifacts into assets. |
| scripts/fetch-player-dependencies.cr | Hooks SABR dependency fetch into existing player dependency fetch flow. |
| scripts/bundle-sabr-libs.js | Bundles googlevideo/bgutils via esbuild for browser use. |
| package.json | Adds Node dependencies and bundling script for SABR libraries. |
| package-lock.json | Locks Node dependencies for bundling SABR libraries. |
| locales/en-US.json | Adds UI strings for SABR preferences and unplayable error label. |
| assets/js/sabr/youtubei.js/versions.yml | Records youtubei.js asset version. |
| assets/js/sabr/shaka-player/versions.yml | Records shaka-player asset version. |
| assets/js/sabr/shaka-player/controls.css | Adds Shaka UI stylesheet asset (with font adjustments). |
| assets/js/sabr/googlevideo/versions.yml | Records googlevideo asset version. |
| assets/js/sabr/bgutils-js/versions.yml | Records bgutils-js asset version. |
| assets/js/sabr/bgutils-js/bgutils.bundle.min.js | Adds bundled bgutils-js browser artifact. |
| assets/js/sabr_ebml_parser.js | Adds EBML parsing helper for SABR segment index parsing. |
| assets/js/sabr_mp4_index.js | Adds MP4 segment index parser for SABR manifests. |
| assets/js/sabr_webm_index.js | Adds WebM segment index parser for SABR manifests. |
| assets/js/sabr_manifest_parser.js | Registers Shaka manifest parser for application/sabr+json. |
| assets/js/sabr_scheme_plugin.js | Registers Shaka networking scheme sabr: and routes traffic through /proxy. |
| assets/js/sabr_helpers.js | Adds shared helper utilities (proxy URL building, Onesie crypto helpers, caching). |
| assets/js/sabr_potoken.js | Adds BotGuard/PoToken generation logic for SABR playback. |
| assets/js/sabr_onesie.js | Adds Onesie player-response fetching for SABR URL portability. |
| assets/js/sabr_player.js | Implements the SABR player orchestrator using Shaka + SABR scheme/manifest. |
| assets/js/sabr_loader.js | Loads ES module SABR dependencies and exposes them on window. |
| assets/js/sabr_init.js | Boots SABR player on page load once SABR libs are loaded. |
| assets/css/sabr_player.css | Adds styling to align Shaka UI with existing player styling. |
| .gitignore | Ignores node_modules/. |
Files not reviewed (1)
- assets/js/sabr/shaka-player/controls.css: Generated file
Suppressed comments (4)
src/invidious/routes/proxy.cr:213
- The proxied response also reflects Origin and sets Access-Control-Allow-Credentials=true. Even if preflight is tightened, this still enables credentialed cross-origin reads for any allowed upstream host. If CORS is needed at all here, avoid Allow-Credentials and only set ACAO when an Origin is present (and add Vary: Origin).
src/invidious/routes/proxy.cr:95 - Custom headers from the __headers query param are currently accepted verbatim, but ALLOWED_HEADERS is not enforced. This allows clients to smuggle arbitrary headers upstream (e.g., Cookie, Host, X-Forwarded-*, etc.), which is a security risk even with an allowlisted host set. Filter __headers to the explicit allowlist (case-insensitive) before copying into request_headers.
src/invidious/routes/proxy.cr:100 - This route uses puts for warning output. Invidious routes elsewhere use LOGGER, which integrates with the app’s log level/formatting. Please use LOGGER.warn here so proxy warnings are captured consistently.
src/invidious/routes/proxy.cr:193 - For POST requests, the proxy reads the entire request body into memory via getb_to_end before forwarding. A malformed or unexpectedly large body can cause high memory usage per request. Consider streaming the request body to the upstream client (or enforcing a strict size limit / Content-Length cap) instead of buffering everything.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "script-src 'self' 'unsafe-eval' 'unsafe-inline'", | ||
| "style-src 'self' 'unsafe-inline'", | ||
| "img-src 'self' data:", | ||
| "font-src 'self' data:", | ||
| "connect-src 'self' " + COMPANION_CSP.companion_urls, | ||
| "connect-src 'self' https://*.googleapis.com https://*.youtube.com " + COMPANION_CSP.companion_urls, |
| <script> | ||
| document.getElementById('quality').addEventListener('change', function() { | ||
| var sabrGroup = document.getElementById('sabr-codec-group'); | ||
| if (this.value === 'sabr') { | ||
| sabrGroup.style.display = ''; |
| # OPTIONS /proxy | ||
| def self.options(env) | ||
| origin = env.request.headers["Origin"]? || "*" | ||
|
|
||
| env.response.headers["Access-Control-Allow-Origin"] = origin | ||
| env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" | ||
| env.response.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS.join(", ") | ||
| env.response.headers["Access-Control-Max-Age"] = "86400" | ||
| env.response.headers["Access-Control-Allow-Credentials"] = "true" | ||
|
|
||
| env.response.status_code = 200 | ||
| "" | ||
| end |
| "dependencies": { | ||
| "googlevideo": "^4.0.4", | ||
| "youtubei.js": "^17.0.1", | ||
| "bgutils-js": "^3.2.0" | ||
| }, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 46 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- assets/js/sabr/shaka-player/controls.css: Generated file
Suppressed comments (8)
src/invidious/routes/before_all.cr:53
- The global CSP is being weakened for every response by adding both 'unsafe-eval' and 'unsafe-inline' to script-src. This substantially increases the blast radius of any XSS (inline script execution + eval) and also affects pages that never load the SABR player. Consider scoping these relaxations to SABR pages only (e.g., watch/embed when quality=sabr), or using nonces/hashes + avoiding eval where possible.
package.json:12 - package.json dependency versions don't match package-lock.json (e.g. package.json requests googlevideo ^4.1.1 / youtubei.js ^17.2.0 / bgutils-js ^4.0.2, but the lockfile pins 4.0.4 / 17.0.1 / 3.2.0). This will produce inconsistent installs depending on whether npm honors the lockfile, and can break bundling/reproducibility. Align the versions (or regenerate the lockfile).
"dependencies": {
"googlevideo": "^4.1.1",
"youtubei.js": "^17.2.0",
"bgutils-js": "^4.0.2"
},
src/invidious/routes/proxy.cr:220
- The proxy response sets Access-Control-Allow-Credentials: true. Removing this avoids credentialed cross-origin reads, and also avoids the invalid "" + credentials combination when Origin is missing (since this route currently falls back to "").
src/invidious/routes/proxy.cr:59 - CORS is currently reflecting the request Origin (or using "") while also advertising Access-Control-Allow-Credentials: true. Using "" with credentials is invalid per the CORS spec, and reflecting arbitrary origins with credentials can allow other sites to read proxied responses. Since /proxy is same-origin for the SABR player, it should not need credentialed CORS at all.
src/invidious/routes/proxy.cr:104 - This route logs parse failures with
puts, which bypasses the app logger and can spam stdout in production. UseLOGGER.warn(or similar) for consistency with the rest of the routes.
assets/js/sabr_player.js:42 - BG_HELPER_URL is hardcoded to http://127.0.0.1:4416/generate, which is the end-user's localhost (browser-side), not the Invidious server. For remote instances this will always fail, and allowing/encouraging browser JS to talk to localhost can have security implications (probing local services). Consider making this a same-origin endpoint (e.g. /bg-helper/generate) and proxying server-side, or gating this feature behind an explicit opt-in.
// bg-helper-server /generate endpoint. Mints an ATTESTED (StreamProtectionStatus=1)
// PO token server-side, where jsdom presents a youtube.com origin. In-browser BotGuard
// (Invidious origin) can only get status 2 (pending), so it's used only as a fallback.
var BG_HELPER_URL = 'http://127.0.0.1:4416/generate';
assets/js/sabr_scheme_plugin.js:308
- The SABR_REDIRECT handler assigns to
currentState.sabrUrl, but the request loop usescurrentState.sabrStreamState.sabrUrl. As written, the assignment has no effect and is misleading (and also contradicts the header comment claiming the redirect bug is fixed). If redirects are intentionally ignored for compatibility, remove this write entirely (or update the comments accordingly).
// (sabrStreamState.sabrUrl). Earlier we "fixed" this to follow the redirect,
// which pins the session to a specific CDN host (rrN---snXXX) that maintains a
// sequential cursor and returns 0 media when the client jumps/seeks. Following
// the redirect breaks seeking; ignoring it (as FreeTube does) keeps seeks working.
currentState.sabrUrl = sabrRedirect.url;
scripts/fetch-sabr-dependencies.cr:5
require "digest/sha1"is unused in this script. Keeping unused requires increases load time and can trip linting/static checks. Remove it if not needed.
require "http"
require "yaml"
require "digest/sha1"
require "option_parser"
require "colorize"
| name = header_array[0]?.try &.as_s | ||
| value = header_array[1]?.try &.as_s | ||
| if name && value | ||
| custom_headers[name] = value | ||
| end |
as explained in LuanRT/googlevideo#43 (comment)
… 5.1.10), companion-optional watch page, no esm.sh
…nnect, fix shaka 5 spinner CSS
Segment index parsers (mp4/webm) declared the per-segment URI array with
`var` inside the loop, so every SegmentReference's getUris() closure
captured the same function-scoped binding and returned the LAST segment's
URL. YouTube received the final segment's startTimeMs/sq for every
segment, replied with policy-only UMP (no media), and the player looped
forever on a black screen ("SABR throttled by YouTube"). Use `let`
(block-scoped, fresh per iteration) to match FreeTube's `const`.
Also scope the big centered play button styling to
`.shaka-play-button-container`; the bare `.shaka-play-button` selector
also matched the control-bar play button (48px siblings), oversizing it
to ~54px and pushing it out of alignment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the plain /youtubei/v1/player request (made through the proxy, and therefore bound to the proxy's egress IP) with an encrypted Onesie request using the WEB client. Onesie is proxied by YouTube's "trusted bandaid", so the returned player response - and the server_abr_streaming_url inside it - is not tied to our egress IP. Media is still pulled over SABR (sabr_scheme_plugin.js); only how the player response is obtained changes. - sabr_onesie.js: new module, window.fetchOnesiePlayerResponse(). Builds the WEB player request, encrypts it (OnesieInnertubeRequest + OnesieRequest), POSTs to the onesie endpoint through /proxy, parses ONESIE_HEADER/ONESIE_DATA UMP parts, gunzips/decrypts, returns the raw player response JSON. Adapted from googlevideo/examples/onesie-request and invidious-secret-companion's WEB-client variant. - sabr_helpers.js: add decryptResponse() (AES-CTR + HMAC verify), companion to the existing encryptRequest(). - sabr_loader.js: expose window.YT so a VideoInfo can be built from the raw onesie player response. - sabr_player.js: in loadVideo(), fetch the player response via Onesie and wrap it in new YT.VideoInfo(...); fall back to innertube.getInfo() on failure. - player.ecr: load sabr_onesie.js before sabr_player.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Checklist
AI Disclosure
Model(s) used (and thinking/reasoning level if relevant):
Claude Opus 4.8
Tool(s) used:
Claude Code
How was AI used?
Was used to replicate the code from https://github.com/LuanRT/kira and freetube's SABR code.
Pull request description
This pull request is for adding SABR support to invidious.
Close #5263
TODO
Worth knowing
Notes
This is not used for loading the video streams from YouTube servers (circumvent YouTube restrictions)in config.yml because wouldn't be true anymore since proxy is in invidious.