diff --git a/examples/README.md b/examples/README.md
index a7b4094..bfa96c0 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,5 +1,5 @@
-## SABR/UMP Player Example
-https://github.com/LuanRT/yt-sabr-shaka-demo
+## SABR/UMP Player
+See [sabr-shaka-example/README.md](./sabr-shaka-example/README.md).
## Downloader Example
diff --git a/examples/sabr-shaka-example/README.md b/examples/sabr-shaka-example/README.md
new file mode 100644
index 0000000..1e06d2a
--- /dev/null
+++ b/examples/sabr-shaka-example/README.md
@@ -0,0 +1,32 @@
+# SABR + Shaka Player Example
+
+This project provides a minimal, self-contained example of how to use [Shaka Player](https://shaka-player-demo.appspot.com/) with the `SabrStreamingAdapter` from the [googlevideo](https://github.com/LuanRT/googlevideo) library to play YouTube videos.
+
+## Note
+
+For an implementation that includes a proper user interface, advanced features, and best practices, please see the main **[Kira](https://github.com/LuanRT/yt-sabr-shaka-demo)** project this example is derived from.
+
+Things **not included** in this minimal example but available in the main project:
+* A proper watch page and user interface.
+* Support for DRM-protected content.
+* A recommendation system and persistent user sessions.
+* Saving and resuming playback position.
+* Advanced error handling and UI feedback.
+* A video/audio downloader.
+
+## How to Run
+
+1. **Install dependencies:**
+ ```bash
+ npm install
+ ```
+
+2. **Start the development server:**
+ ```bash
+ npm run dev
+ ```
+
+3. Open your browser and navigate to the local URL provided by Vite (e.g., `http://localhost:5173`).
+
+## License
+Distributed under the [MIT](./LICENSE) License.
\ No newline at end of file
diff --git a/examples/sabr-shaka-example/index.html b/examples/sabr-shaka-example/index.html
new file mode 100644
index 0000000..b6ce0dc
--- /dev/null
+++ b/examples/sabr-shaka-example/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ SABR Shaka Player Example
+
+
+
+
+
+ Load Video
+
+
+
+
+ Ready. Enter a video ID and click "Load Video".
+
+
+
+
\ No newline at end of file
diff --git a/examples/sabr-shaka-example/package-lock.json b/examples/sabr-shaka-example/package-lock.json
new file mode 100644
index 0000000..237abc5
--- /dev/null
+++ b/examples/sabr-shaka-example/package-lock.json
@@ -0,0 +1,1025 @@
+{
+ "name": "sabr-shaka-example",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "sabr-shaka-example",
+ "version": "1.0.0",
+ "hasInstallScript": true,
+ "dependencies": {
+ "bgutils-js": "^3.2.0",
+ "googlevideo": "^4.0.4",
+ "shaka-player": "^4.16.2",
+ "youtubei.js": "^15.1.1"
+ },
+ "devDependencies": {
+ "typescript": "^5.4.5",
+ "vite": "^5.2.11"
+ }
+ },
+ "node_modules/@bufbuild/protobuf": {
+ "version": "2.8.0",
+ "license": "(Apache-2.0 AND BSD-3-Clause)"
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.50.2",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/bgutils-js": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-3.2.0.tgz",
+ "integrity": "sha512-CacO15JvxbclbLeCAAm9DETGlLuisRGWpPigoRvNsccSCPEC4pwYwA2g2x/pv7Om/sk79d4ib35V5HHmxPBpDg==",
+ "funding": [
+ "https://github.com/sponsors/LuanRT"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/googlevideo": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/googlevideo/-/googlevideo-4.0.4.tgz",
+ "integrity": "sha512-S/rfuoPBI+qXCEUPJeVhXsHoISMgVhOz8hHSpGWa0OztfHhh+g9EKaEcqAb/+ttO7meoNQNqIy9dfIpz7HPc4g==",
+ "funding": [
+ "https://github.com/sponsors/LuanRT"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@bufbuild/protobuf": "^2.0.0"
+ }
+ },
+ "node_modules/jintr": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jintr/-/jintr-3.3.1.tgz",
+ "integrity": "sha512-nnOzyhf0SLpbWuZ270Omwbj5LcXUkTcZkVnK8/veJXtSZOiATM5gMZMdmzN75FmTyj+NVgrGaPdH12zIJ24oIA==",
+ "funding": [
+ "https://github.com/sponsors/LuanRT"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.8.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.50.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.50.2",
+ "@rollup/rollup-android-arm64": "4.50.2",
+ "@rollup/rollup-darwin-arm64": "4.50.2",
+ "@rollup/rollup-darwin-x64": "4.50.2",
+ "@rollup/rollup-freebsd-arm64": "4.50.2",
+ "@rollup/rollup-freebsd-x64": "4.50.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.50.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.50.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.50.2",
+ "@rollup/rollup-linux-arm64-musl": "4.50.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.50.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.50.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.50.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.50.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.50.2",
+ "@rollup/rollup-linux-x64-gnu": "4.50.2",
+ "@rollup/rollup-linux-x64-musl": "4.50.2",
+ "@rollup/rollup-openharmony-arm64": "4.50.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.50.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.50.2",
+ "@rollup/rollup-win32-x64-msvc": "4.50.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz",
+ "integrity": "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz",
+ "integrity": "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz",
+ "integrity": "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz",
+ "integrity": "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz",
+ "integrity": "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz",
+ "integrity": "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz",
+ "integrity": "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz",
+ "integrity": "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz",
+ "integrity": "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz",
+ "integrity": "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz",
+ "integrity": "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz",
+ "integrity": "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz",
+ "integrity": "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz",
+ "integrity": "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz",
+ "integrity": "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz",
+ "integrity": "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz",
+ "integrity": "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz",
+ "integrity": "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz",
+ "integrity": "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.50.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz",
+ "integrity": "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/rollup/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/shaka-player": {
+ "version": "4.16.2",
+ "resolved": "https://registry.npmjs.org/shaka-player/-/shaka-player-4.16.2.tgz",
+ "integrity": "sha512-AFswL7ZXT37UceDAsovQXQ3BRGLmPcmdgXgLVJdmHN2mnrYCvTrl7CGg2jeIPxUOHipAeeq33LrDM9uGBlQRVA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "6.21.3",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
+ "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.20",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/youtubei.js": {
+ "version": "15.1.1",
+ "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-15.1.1.tgz",
+ "integrity": "sha512-fuEDj9Ky6cAQg93BrRVCbr+GTYNZQAIFZrx/a3oDRuGc3Mf5bS0dQfoYwwgjtSV7sgAKQEEdGtzRdBzOc8g72Q==",
+ "funding": [
+ "https://github.com/sponsors/LuanRT"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@bufbuild/protobuf": "^2.0.0",
+ "jintr": "^3.3.1",
+ "undici": "^6.21.3"
+ }
+ }
+ }
+}
diff --git a/examples/sabr-shaka-example/package.json b/examples/sabr-shaka-example/package.json
new file mode 100644
index 0000000..69fce6b
--- /dev/null
+++ b/examples/sabr-shaka-example/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "sabr-shaka-example",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "patch:shaka": "node ./scripts/patchShaka.mjs",
+ "postinstall": "npm run patch:shaka"
+ },
+ "dependencies": {
+ "bgutils-js": "^3.2.0",
+ "googlevideo": "^4.0.4",
+ "shaka-player": "^4.16.2",
+ "youtubei.js": "^15.1.1"
+ },
+ "devDependencies": {
+ "typescript": "^5.4.5",
+ "vite": "^5.2.11"
+ }
+}
diff --git a/examples/sabr-shaka-example/scripts/patchShaka.mjs b/examples/sabr-shaka-example/scripts/patchShaka.mjs
new file mode 100644
index 0000000..0b4c986
--- /dev/null
+++ b/examples/sabr-shaka-example/scripts/patchShaka.mjs
@@ -0,0 +1,30 @@
+import { readFile, appendFile } from 'node:fs/promises';
+import path from 'node:path';
+import * as url from 'node:url';
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+
+async function main() {
+ const fileNames = [ 'shaka-player.ui.d.ts', 'shaka-player.ui.debug.d.ts' ];
+
+ for (const filename of fileNames) {
+ await fixTypes(filename);
+ }
+}
+
+async function fixTypes(filename) {
+ const filePath = path.join(__dirname, '..', 'node_modules', 'shaka-player', 'dist', filename);
+
+ const shakaTs = await readFile(filePath, 'utf-8');
+
+ if (!shakaTs.includes('export default shaka')) {
+ await appendFile(filePath, 'export default shaka;');
+ console.log(`[PatchShaka] Fixed types in ${filename}`);
+ } else {
+ console.log(`[PatchShaka] No changes needed in ${filename}`);
+ }
+}
+
+main().catch(() => {
+ console.error('[PatchShaka]', 'Failed to patch shaka-player');
+});
\ No newline at end of file
diff --git a/examples/sabr-shaka-example/src/BotguardService.ts b/examples/sabr-shaka-example/src/BotguardService.ts
new file mode 100644
index 0000000..1ae0b71
--- /dev/null
+++ b/examples/sabr-shaka-example/src/BotguardService.ts
@@ -0,0 +1,130 @@
+import { fetchFunction } from './helpers.js';
+import type { DescrambledChallenge, WebPoSignalOutput } from 'bgutils-js';
+import { BG, buildURL, GOOG_API_KEY } from 'bgutils-js';
+
+export class BotguardService {
+ private readonly waaRequestKey = 'O43z0dpjhgX20SCx4KAo';
+
+ public botguardClient?: BG.BotGuardClient;
+ public initializationPromise?: Promise | null = null;
+ public integrityTokenBasedMinter?: BG.WebPoMinter;
+ public bgChallenge?: DescrambledChallenge & { challenge?: string, interpreterUrl?: string };
+
+ async init() {
+ if (this.initializationPromise) {
+ return await this.initializationPromise;
+ }
+
+ return this.setup();
+ }
+
+ private async setup() {
+ if (this.initializationPromise)
+ return await this.initializationPromise;
+
+ this.initializationPromise = this._initBotguard();
+
+ try {
+ this.botguardClient = await this.initializationPromise;
+ return this.botguardClient;
+ } finally {
+ this.initializationPromise = null;
+ }
+ }
+
+ private async _initBotguard() {
+ const challengeResponse = await fetch(buildURL('Create', true), {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json+protobuf',
+ 'x-goog-api-key': GOOG_API_KEY,
+ 'x-user-agent': 'grpc-web-javascript/0.1'
+ },
+ body: JSON.stringify([ this.waaRequestKey ])
+ });
+
+ const challengeResponseData = await challengeResponse.json();
+ this.bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
+
+ if (!this.bgChallenge)
+ return;
+
+ const interpreterJavascript = this.bgChallenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
+
+ if (!interpreterJavascript) {
+ console.error('[BotguardService]', 'Could not get interpreter javascript. Interpreter Hash:', this.bgChallenge.interpreterHash);
+ return;
+ }
+
+ if (!document.getElementById(this.bgChallenge.interpreterHash)) {
+ const script = document.createElement('script');
+ script.type = 'text/javascript';
+ script.id = this.bgChallenge.interpreterHash;
+ script.textContent = interpreterJavascript;
+ document.head.appendChild(script);
+ }
+
+ this.botguardClient = await BG.BotGuardClient.create({
+ globalObj: globalThis,
+ globalName: this.bgChallenge.globalName,
+ program: this.bgChallenge.program
+ });
+
+ if (this.bgChallenge) {
+ const webPoSignalOutput: WebPoSignalOutput = [];
+ const botguardResponse = await this.botguardClient.snapshot({ webPoSignalOutput });
+
+ const integrityTokenResponse = await fetchFunction(buildURL('GenerateIT', true), {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json+protobuf',
+ 'x-goog-api-key': GOOG_API_KEY,
+ 'x-user-agent': 'grpc-web-javacript/0.1'
+ },
+ body: JSON.stringify([ this.waaRequestKey, botguardResponse ])
+ });
+
+ const integrityTokenResponseData = await integrityTokenResponse.json();
+ const integrityToken = integrityTokenResponseData[0] as string | undefined;
+
+ if (!integrityToken) {
+ console.error('[BotguardService]', 'Could not get integrity token. Interpreter Hash:', this.bgChallenge.interpreterHash);
+ return;
+ }
+
+ this.integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken }, webPoSignalOutput);
+ }
+
+ return this.botguardClient;
+ }
+
+ public mintColdStartToken(contentBinding: string) {
+ return BG.PoToken.generateColdStartToken(contentBinding);
+ }
+
+ public isInitialized() {
+ return !!this.botguardClient && !!this.integrityTokenBasedMinter;
+ }
+
+ public dispose() {
+ if (this.botguardClient && this.bgChallenge) {
+ this.botguardClient.shutdown();
+ this.botguardClient = undefined;
+ this.integrityTokenBasedMinter = undefined;
+
+ const script = document.getElementById(this.bgChallenge.interpreterHash);
+ if (script) {
+ script.remove();
+ }
+ }
+ }
+
+ public async reinit() {
+ if (this.initializationPromise)
+ return this.initializationPromise;
+ this.dispose();
+ return this.setup();
+ }
+}
+
+export const botguardService = new BotguardService();
\ No newline at end of file
diff --git a/examples/sabr-shaka-example/src/ShakaPlayerAdapter.ts b/examples/sabr-shaka-example/src/ShakaPlayerAdapter.ts
new file mode 100644
index 0000000..6f6c20e
--- /dev/null
+++ b/examples/sabr-shaka-example/src/ShakaPlayerAdapter.ts
@@ -0,0 +1,498 @@
+import shaka from 'shaka-player/dist/shaka-player.ui';
+
+import { FormatKeyUtils, type CacheManager, type RequestMetadataManager, isGoogleVideoURL } from 'googlevideo/utils';
+
+import type { SabrFormat } from 'googlevideo/shared-types';
+
+import {
+ SabrUmpProcessor,
+ type RequestFilter,
+ type ResponseFilter,
+ type SabrPlayerAdapter,
+ type SabrRequestMetadata,
+ type UmpProcessingResult
+} from 'googlevideo/sabr-streaming-adapter';
+
+import {
+ asMap, checkExtension,
+ createRecoverableError,
+ getInjectedProxyFunction,
+ headersToGenericObject,
+ makeResponse
+} from './helpers.js';
+
+interface ShakaResponseArgs {
+ uri: string;
+ request: shaka.extern.Request;
+ requestType: shaka.net.NetworkingEngine.RequestType;
+ response: Response;
+ arrayBuffer?: Uint8Array | ArrayBuffer;
+}
+
+export class ShakaPlayerAdapter implements SabrPlayerAdapter {
+ protected player: shaka.Player | null = null;
+ private requestMetadataManager?: RequestMetadataManager;
+ private cacheManager?: CacheManager;
+ private abortController?: AbortController;
+
+ private requestFilter?: (type: shaka.net.NetworkingEngine.RequestType, request: shaka.extern.Request, context?: shaka.extern.RequestContext) => Promise;
+ private responseFilter?: (type: shaka.net.NetworkingEngine.RequestType, response: shaka.extern.Response, context?: shaka.extern.RequestContext) => Promise;
+
+ public initialize(
+ player: shaka.Player,
+ requestMetadataManager: RequestMetadataManager,
+ cacheManager: CacheManager
+ ): void {
+ this.player = player;
+ this.requestMetadataManager = requestMetadataManager;
+ this.cacheManager = cacheManager;
+
+ const networkingEngine = shaka.net.NetworkingEngine;
+ const schemes = [ 'http', 'https' ];
+
+ if (!shaka.net.HttpFetchPlugin.isSupported())
+ throw new Error('The Fetch API is not supported in this browser.');
+
+ schemes.forEach((scheme) => {
+ networkingEngine.registerScheme(
+ scheme, this.parseRequest.bind(this),
+ networkingEngine.PluginPriority.PREFERRED
+ );
+ });
+ }
+
+ private parseRequest(
+ uri: string,
+ request: shaka.extern.Request,
+ requestType: shaka.net.NetworkingEngine.RequestType,
+ progressUpdated: shaka.extern.ProgressUpdated,
+ headersReceived: shaka.extern.HeadersReceived,
+ config: shaka.extern.SchemePluginConfig
+ ): shaka.extern.IAbortableOperation {
+ const headers = new Headers();
+ asMap(request.headers).forEach((value, key) => {
+ headers.append(key as string, value);
+ });
+
+ const controller = new AbortController();
+ this.abortController = controller;
+
+ const init: RequestInit = {
+ body: request.body as any || undefined,
+ headers,
+ method: request.method,
+ signal: this.abortController.signal,
+ credentials: request.allowCrossSiteCredentials ? 'include' : undefined
+ };
+
+ const abortStatus = { canceled: false, timedOut: false };
+
+ const minBytes = config.minBytesForProgressEvents || 0;
+
+ const pendingRequest = this.request(uri, request, requestType, init, controller, abortStatus, progressUpdated, headersReceived, minBytes);
+
+ const operation = new shaka.util.AbortableOperation(
+ pendingRequest,
+ () => {
+ abortStatus.canceled = true;
+ controller.abort();
+ return Promise.resolve();
+ }
+ );
+
+ const timeoutMs = request.retryParameters.timeout;
+ if (timeoutMs) {
+ const timer = new shaka.util.Timer(() => {
+ abortStatus.timedOut = true;
+ controller.abort();
+ console.warn('[ShakaPlayerAdapter]', 'Request aborted due to timeout:', uri, requestType);
+ });
+ timer.tickAfter(timeoutMs / 1000);
+ operation.finally(() => timer.stop());
+ }
+
+ return operation;
+ }
+
+ private async handleCachedRequest(
+ requestMetadata: SabrRequestMetadata,
+ uri: string,
+ request: shaka.extern.Request,
+ progressUpdated: shaka.extern.ProgressUpdated,
+ headersReceived: shaka.extern.HeadersReceived,
+ requestType: shaka.net.NetworkingEngine.RequestType
+ ): Promise {
+ if (!requestMetadata.byteRange || !this.cacheManager) {
+ return null;
+ }
+
+ const segmentKey = FormatKeyUtils.createSegmentCacheKeyFromMetadata(requestMetadata);
+
+ let arrayBuffer = (
+ requestMetadata.isInit ?
+ this.cacheManager.getInitSegment(segmentKey) :
+ this.cacheManager.getSegment(segmentKey)
+ )?.buffer as ArrayBuffer;
+
+ if (!arrayBuffer) {
+ return null;
+ }
+
+ if (requestMetadata.isInit) {
+ arrayBuffer = arrayBuffer.slice(
+ requestMetadata.byteRange.start,
+ requestMetadata.byteRange.end + 1
+ );
+ }
+
+ const headers = {
+ 'content-type': requestMetadata.format?.mimeType?.split(';')[0] || '',
+ 'content-length': arrayBuffer.byteLength.toString(),
+ 'x-shaka-from-cache': 'true'
+ };
+
+ headersReceived(headers);
+ progressUpdated(0, arrayBuffer.byteLength, 0);
+
+ return makeResponse(headers, arrayBuffer, 200, uri, uri, request, requestType);
+ }
+
+ private async handleUmpResponse(
+ response: Response,
+ requestMetadata: SabrRequestMetadata,
+ uri: string,
+ request: shaka.extern.Request,
+ requestType: shaka.net.NetworkingEngine.RequestType,
+ progressUpdated: shaka.extern.ProgressUpdated,
+ abortController: AbortController,
+ minBytes: number
+ ): Promise {
+ let lastTime = Date.now();
+
+ const sabrUmpReader = new SabrUmpProcessor(requestMetadata, this.cacheManager);
+
+ const checkResultIntegrity = (result: UmpProcessingResult) => {
+ if (!result.data && ((!!requestMetadata.error || requestMetadata.streamInfo?.streamProtectionStatus?.status === 3) && !requestMetadata.streamInfo?.sabrContextUpdate)) {
+ throw createRecoverableError('Server streaming error', requestMetadata);
+ }
+ };
+
+ const shouldReturnEmptyResponse = () => {
+ return requestMetadata.isSABR && (requestMetadata.streamInfo?.redirect || requestMetadata.streamInfo?.sabrContextUpdate);
+ };
+
+ // Fetch returning a ReadableStream response body is not currently
+ // supported by all browsers.
+ // Browser compatibility:
+ // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
+ // If it is not supported, returning the whole segment when
+ // it's ready (as xhr)
+ if (!response.body) {
+ const arrayBuffer = await response.arrayBuffer();
+ const currentTime = Date.now();
+
+ progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
+
+ const result = await sabrUmpReader.processChunk(new Uint8Array(arrayBuffer));
+
+ if (result) {
+ checkResultIntegrity(result);
+ return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: result.data });
+ }
+
+ if (shouldReturnEmptyResponse()) {
+ return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: undefined });
+ }
+
+ throw createRecoverableError('Empty response with no redirect information', requestMetadata);
+ } else {
+ const reader = response.body.getReader();
+
+ let loaded = 0;
+ let lastLoaded = 0;
+ let contentLength;
+
+ while (!abortController.signal.aborted) {
+ let readObj;
+ try {
+ readObj = await reader.read();
+ } catch {
+ // If we abort the request while reading, we'll get an error here. Just ignore it.
+ break;
+ }
+
+ const { value, done } = readObj;
+
+ if (done) {
+ // If we got here, we read the whole response but there was no segment data; it means we must follow a
+ // redirect, or handle protocol updates.
+ if (shouldReturnEmptyResponse()) {
+ return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: undefined });
+ }
+ throw createRecoverableError('Empty response with no redirect information', requestMetadata);
+ }
+
+ const result = await sabrUmpReader.processChunk(value);
+
+ const segmentInfo = sabrUmpReader.getSegmentInfo();
+
+ if (segmentInfo) {
+ if (!contentLength) {
+ contentLength = segmentInfo.mediaHeader.contentLength;
+ }
+
+ loaded += segmentInfo.lastChunkSize || 0;
+ segmentInfo.lastChunkSize = 0;
+ }
+
+ const currentTime = Date.now();
+ const chunkSize = loaded - lastLoaded;
+
+ // If the time between last time and this time we got
+ // progress event is long enough, or if a whole segment
+ // is downloaded, call progressUpdated().
+ if ((currentTime - lastTime > 100 && chunkSize >= minBytes) || result) {
+ // If we have a result, check its integrity before attempting anything.
+ if (result) checkResultIntegrity(result);
+ if (contentLength) {
+ const numBytesRemaining = result ? 0 : parseInt(contentLength) - loaded;
+ try {
+ progressUpdated(currentTime - lastTime, chunkSize, numBytesRemaining);
+ } catch { /** no-op */
+ } finally {
+ lastLoaded = loaded;
+ lastTime = currentTime;
+ }
+ }
+ }
+
+ if (result) {
+ abortController.abort();
+ return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: result.data });
+ }
+ }
+
+ // Unreachable if the loop is aborted correctly.
+ throw createRecoverableError('UMP stream processing was aborted but did not produce a result.', requestMetadata);
+ }
+ }
+
+ private async request(
+ uri: string,
+ request: shaka.extern.Request,
+ requestType: shaka.net.NetworkingEngine.RequestType,
+ init: RequestInit,
+ abortController: AbortController,
+ abortStatus: { canceled: boolean; timedOut: boolean },
+ progressUpdated: shaka.extern.ProgressUpdated,
+ headersReceived: shaka.extern.HeadersReceived,
+ minBytes: number
+ ): Promise {
+ try {
+ const requestMetadata = this.requestMetadataManager?.getRequestMetadata(uri);
+
+ // Check the cache first.
+ if (requestMetadata) {
+ const cachedResponse = await this.handleCachedRequest(requestMetadata, uri, request, progressUpdated, headersReceived, requestType);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ }
+
+ // We only make one InnerTube request through the player, and it needs to be proxied properly.
+ const fetchFn = uri.includes('get_drm_license') && checkExtension() ? getInjectedProxyFunction() : fetch;
+
+ const response = await fetchFn(uri, init);
+ headersReceived(headersToGenericObject(response.headers));
+
+ if (requestMetadata && init.method !== 'HEAD' && response.headers.get('content-type') === 'application/vnd.yt-ump') {
+ return this.handleUmpResponse(response, requestMetadata, uri, request, requestType, progressUpdated, abortController, minBytes);
+ }
+
+ // Handle other requests normally.
+ const lastTime = Date.now();
+
+ const arrayBuffer = await response.arrayBuffer();
+ const currentTime = Date.now();
+
+ progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
+
+ return this.createShakaResponse({
+ uri,
+ request,
+ requestType,
+ response,
+ arrayBuffer
+ });
+ } catch (error) {
+ if (abortStatus.canceled) {
+ throw new shaka.util.Error(
+ shaka.util.Error.Severity.RECOVERABLE,
+ shaka.util.Error.Category.NETWORK,
+ shaka.util.Error.Code.OPERATION_ABORTED,
+ uri, requestType
+ );
+ } else if (abortStatus.timedOut) {
+ throw new shaka.util.Error(
+ shaka.util.Error.Severity.RECOVERABLE,
+ shaka.util.Error.Category.NETWORK,
+ shaka.util.Error.Code.TIMEOUT,
+ uri, requestType
+ );
+ }
+ throw new shaka.util.Error(
+ shaka.util.Error.Severity.RECOVERABLE,
+ shaka.util.Error.Category.NETWORK,
+ shaka.util.Error.Code.HTTP_ERROR,
+ uri, error, requestType
+ );
+ }
+ }
+
+ public checkPlayerStatus(): asserts this is ({ player: shaka.Player } & this) {
+ if (!this.player) {
+ throw new Error('Player not initialized');
+ }
+ }
+
+ public getPlayerTime() {
+ this.checkPlayerStatus();
+ return this.player.getMediaElement()?.currentTime || 0;
+ }
+
+ public getPlaybackRate() {
+ this.checkPlayerStatus();
+ return this.player.getPlaybackRate();
+ }
+
+ public getBandwidthEstimate() {
+ this.checkPlayerStatus();
+ return this.player.getStats().estimatedBandwidth;
+ }
+
+ public getActiveTrackFormats(activeFormat: SabrFormat, sabrFormats: SabrFormat[]): {
+ videoFormat?: SabrFormat;
+ audioFormat?: SabrFormat
+ } {
+ this.checkPlayerStatus();
+
+ const activeVariant = this.player.getVariantTracks().find((track) =>
+ FormatKeyUtils.getUniqueFormatId(activeFormat) === (activeFormat.width ? track.originalVideoId : track.originalAudioId)
+ );
+
+ if (!activeVariant) {
+ return { videoFormat: undefined, audioFormat: undefined };
+ }
+
+ const formatMap = new Map(sabrFormats.map((format) => [ FormatKeyUtils.getUniqueFormatId(format), format ]));
+
+ return {
+ videoFormat: activeVariant.originalVideoId ? formatMap.get(activeVariant.originalVideoId) : undefined,
+ audioFormat: activeVariant.originalAudioId ? formatMap.get(activeVariant.originalAudioId) : undefined
+ };
+ }
+
+ public registerRequestInterceptor(interceptor: RequestFilter): void {
+ this.checkPlayerStatus();
+
+ const networkingEngine = this.player.getNetworkingEngine();
+ if (!networkingEngine)
+ return;
+
+ this.requestFilter = async (type, request, context) => {
+ if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || !isGoogleVideoURL(request.uris[0])) return;
+
+ const modifiedRequest = await interceptor({
+ headers: request.headers,
+ url: request.uris[0],
+ method: request.method,
+ segment: {
+ getStartTime: () => context?.segment?.getStartTime() ?? null,
+ isInit: () => !context?.segment
+ },
+ body: request.body
+ });
+
+ if (modifiedRequest) {
+ request.uris = modifiedRequest.url ? [ modifiedRequest.url ] : request.uris;
+ request.method = modifiedRequest.method || request.method;
+ request.headers = modifiedRequest.headers || request.headers;
+ request.body = modifiedRequest.body || request.body;
+ }
+ };
+
+ networkingEngine.registerRequestFilter(this.requestFilter);
+ }
+
+ public registerResponseInterceptor(interceptor: ResponseFilter): void {
+ this.checkPlayerStatus();
+ const networkingEngine = this.player.getNetworkingEngine();
+ if (!networkingEngine) return;
+
+ this.responseFilter = async (type, response, context) => {
+ if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || !isGoogleVideoURL(response.uri)) return;
+
+ const modifiedResponse = await interceptor({
+ url: response.originalRequest.uris[0],
+ method: response.originalRequest.method,
+ headers: response.headers,
+ data: response.data,
+ makeRequest: async (url: string, headers: Record) => {
+ const retryParameters = this.player!.getConfiguration().streaming.retryParameters;
+ const redirectRequest = shaka.net.NetworkingEngine.makeRequest([ url ], retryParameters);
+ Object.assign(redirectRequest.headers, headers);
+
+ const requestOperation = networkingEngine.request(type, redirectRequest, context);
+ const redirectResponse = await requestOperation.promise;
+
+ return {
+ url: redirectResponse.uri,
+ method: redirectResponse.originalRequest.method,
+ headers: redirectResponse.headers,
+ data: redirectResponse.data
+ };
+ }
+ });
+
+ if (modifiedResponse) {
+ response.data = modifiedResponse.data ?? response.data;
+ Object.assign(response.headers, modifiedResponse.headers);
+ }
+ };
+
+ networkingEngine.registerResponseFilter(this.responseFilter);
+ }
+
+ public createShakaResponse(args: ShakaResponseArgs): shaka.extern.Response {
+ return makeResponse(
+ headersToGenericObject(args.response.headers),
+ args.arrayBuffer as any || new ArrayBuffer(0),
+ args.response.status,
+ args.uri,
+ args.response.url,
+ args.request,
+ args.requestType
+ );
+ }
+
+ public dispose(): void {
+ if (this.abortController) {
+ this.abortController.abort();
+ this.abortController = undefined;
+ }
+
+ if (this.player) {
+ const networkingEngine = this.player.getNetworkingEngine();
+
+ if (networkingEngine && this.requestFilter && this.responseFilter) {
+ networkingEngine.unregisterRequestFilter(this.requestFilter);
+ networkingEngine.unregisterResponseFilter(this.responseFilter);
+ }
+
+ shaka.net.NetworkingEngine.unregisterScheme('http');
+ shaka.net.NetworkingEngine.unregisterScheme('https');
+
+ this.player = null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/sabr-shaka-example/src/helpers.ts b/examples/sabr-shaka-example/src/helpers.ts
new file mode 100644
index 0000000..a31c5e3
--- /dev/null
+++ b/examples/sabr-shaka-example/src/helpers.ts
@@ -0,0 +1,101 @@
+import shaka from 'shaka-player/dist/shaka-player.ui';
+
+export function checkExtension(): boolean {
+ return 'ytcBridge' in window && (window as any).ytcBridge.installed;
+}
+
+export function getInjectedProxyFunction() {
+ return (window as any).proxyFetch;
+}
+
+export async function fetchFunction(input: string | Request | URL, init?: RequestInit): Promise {
+ const url = input instanceof URL ? input : new URL(typeof input === 'string' ? input : input.url);
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
+ const requestInit = { ...init, headers };
+
+ if (url.pathname.includes('v1/player')) {
+ url.searchParams.set('$fields', 'playerConfig,storyboards,captions,playabilityStatus,streamingData,responseContext.mainAppWebResponseContext.datasyncId,videoDetails.isLive,videoDetails.isLiveContent,videoDetails.title,videoDetails.author,videoDetails.thumbnail');
+ }
+
+ const proxyFetch = getInjectedProxyFunction();
+
+ if (proxyFetch) {
+ if (url.pathname.includes('initplayback')) {
+ return fetch(url, requestInit);
+ }
+ return proxyFetch(url.toString(), requestInit);
+ }
+
+ throw new Error('Proxy fetch function not found.');
+}
+
+export function asMap(object: Record): Map {
+ const map = new Map();
+ for (const key of Object.keys(object)) {
+ map.set(key as K, object[key]);
+ }
+ return map;
+}
+
+export function createRecoverableError(message: string, info?: Record) {
+ return new shaka.util.Error(
+ shaka.util.Error.Severity.RECOVERABLE,
+ shaka.util.Error.Category.NETWORK,
+ shaka.util.Error.Code.HTTP_ERROR,
+ message,
+ { info }
+ );
+}
+
+export function headersToGenericObject(headers: Headers): Record {
+ const headersObj: Record = {};
+ headers.forEach((value, key) => {
+ // Since Edge incorrectly returns the header with a leading new line
+ // character ('\n'), we trim the header here.
+ headersObj[key.trim()] = value;
+ });
+ return headersObj;
+}
+
+export function makeResponse(
+ headers: Record,
+ data: BufferSource,
+ status: number,
+ uri: string,
+ responseURL: string,
+ request: shaka.extern.Request,
+ requestType: shaka.net.NetworkingEngine.RequestType
+): shaka.extern.Response & { originalRequest: shaka.extern.Request } {
+ if (status >= 200 && status <= 299 && status !== 202) {
+ return {
+ uri: responseURL || uri,
+ originalUri: uri,
+ data,
+ status,
+ headers,
+ originalRequest: request,
+ fromCache: !!headers['x-shaka-from-cache']
+ };
+ }
+
+ let responseText: string | null = null;
+ try {
+ responseText = shaka.util.StringUtils.fromBytesAutoDetect(data);
+ } catch { /* no-op */ }
+
+ const severity = status === 401 || status === 403
+ ? shaka.util.Error.Severity.CRITICAL
+ : shaka.util.Error.Severity.RECOVERABLE;
+
+ throw new shaka.util.Error(
+ severity,
+ shaka.util.Error.Category.NETWORK,
+ shaka.util.Error.Code.BAD_HTTP_STATUS,
+ uri,
+ status,
+ responseText,
+ headers,
+ requestType,
+ responseURL || uri
+ );
+}
diff --git a/examples/sabr-shaka-example/src/main.ts b/examples/sabr-shaka-example/src/main.ts
new file mode 100644
index 0000000..cded9d1
--- /dev/null
+++ b/examples/sabr-shaka-example/src/main.ts
@@ -0,0 +1,248 @@
+import shaka from 'shaka-player/dist/shaka-player.ui.js';
+import { Innertube, UniversalCache, YT, Utils, Constants } from 'youtubei.js';
+import { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
+import { buildSabrFormat } from 'googlevideo/utils';
+import { ShakaPlayerAdapter } from './ShakaPlayerAdapter.js';
+import { checkExtension, fetchFunction } from './helpers.js';
+import { botguardService } from './BotguardService.js';
+import 'shaka-player/dist/controls.css';
+
+const videoElement = document.getElementById('video') as HTMLVideoElement;
+const videoContainer = document.getElementById('video-container') as HTMLDivElement;
+const videoIdInput = document.getElementById('videoIdInput') as HTMLInputElement;
+const loadButton = document.getElementById('loadButton') as HTMLButtonElement;
+const statusElement = document.getElementById('status') as HTMLDivElement;
+
+let player: shaka.Player;
+let sabrAdapter: SabrStreamingAdapter;
+let innertube: Innertube;
+let sessionPoTokenContentBinding: string | undefined;
+let sessionPoTokenCreationLock = false;
+let sessionPoToken: string | undefined;
+let coldStartToken: string | undefined;
+
+async function main() {
+ shaka.polyfill.installAll();
+
+ if (!shaka.Player.isBrowserSupported())
+ throw new Error('Shaka Player is not supported on this browser.');
+
+ if (!checkExtension()) {
+ throw new Error('This application requires the "ytc-bridge" browser extension to function. This extension is needed to communicate with YouTube\'s internal APIs by bypassing browser security restrictions (like CORS) that would otherwise block requests. Please install the extension from https://github.com/LuanRT/ytc-bridge and then reload the page.');
+ }
+
+ innertube = await Innertube.create({
+ cache: new UniversalCache(true),
+ fetch: fetchFunction
+ });
+
+ botguardService.init().then(() => console.info('[App]', 'BotGuard client initialized'));
+
+ sessionPoTokenContentBinding = innertube.session.context.client.visitorData;
+
+ console.log('[Main] Innertube initialized');
+
+ // Now init the player.
+ player = new shaka.Player(videoElement);
+ player.configure({
+ abr: { enabled: true },
+ streaming: {
+ bufferingGoal: 120,
+ rebufferingGoal: 2
+ }
+ });
+
+ const ui = new shaka.ui.Overlay(player, videoContainer, videoElement);
+
+ ui.configure({
+ addBigPlayButton: false,
+ overflowMenuButtons: [
+ 'captions',
+ 'quality',
+ 'language',
+ 'chapter',
+ 'picture_in_picture',
+ 'playback_rate',
+ 'loop',
+ 'recenter_vr',
+ 'toggle_stereoscopic',
+ 'save_video_frame'
+ ],
+ customContextMenu: true
+ });
+
+ const volumeContainer = videoContainer.getElementsByClassName('shaka-volume-bar-container');
+ volumeContainer[0].addEventListener('mousewheel', (event) => {
+ event.preventDefault();
+ const delta = Math.sign((event as any).deltaY);
+ const newVolume = Math.max(0, Math.min(1, videoElement.volume - delta * 0.05));
+ videoElement.volume = newVolume;
+ });
+
+ console.log('[Main] Shaka Player initialized');
+
+ // Set up UI listeners.
+ loadButton.addEventListener('click', () => loadVideo(videoIdInput.value));
+ loadButton.disabled = false;
+ statusElement.textContent = 'Ready. Enter a video ID and click "Load Video".';
+}
+
+async function loadVideo(videoId: string) {
+ if (!videoId) {
+ alert('Please enter a video ID.');
+ return;
+ }
+
+ statusElement.textContent = `Loading video: ${videoId}...`;
+ console.log('[Player]', `Loading video: ${videoId}`);
+
+ try {
+ // Unload previous video.
+ await player.unload();
+
+ if (sabrAdapter) {
+ sabrAdapter.dispose();
+ }
+
+ // Now fetch video info from YouTube.
+ const playerResponse = await innertube.actions.execute('/player', {
+ videoId,
+ contentCheckOk: true,
+ racyCheckOk: true,
+ playbackContext: {
+ adPlaybackContext: {
+ pyv: true
+ },
+ contentPlaybackContext: {
+ signatureTimestamp: innertube.session.player?.sts
+ }
+ }
+ });
+
+ const cpn = Utils.generateRandomString(16);
+ const videoInfo = new YT.VideoInfo([ playerResponse ], innertube.actions, cpn);
+
+ if (videoInfo.playability_status?.status !== 'OK') {
+ throw new Error(`Cannot play video: ${videoInfo.playability_status?.reason}`);
+ }
+
+ const isLive = videoInfo.basic_info.is_live;
+ const isPostLiveDVR = !!videoInfo.basic_info.is_post_live_dvr ||
+ (videoInfo.basic_info.is_live_content && !!(videoInfo.streaming_data?.dash_manifest_url || videoInfo.streaming_data?.hls_manifest_url));
+
+ // Initialize and attach SABR adapter.
+ sabrAdapter = new SabrStreamingAdapter({
+ playerAdapter: new ShakaPlayerAdapter(),
+ clientInfo: {
+ osName: innertube.session.context.client.osName,
+ osVersion: innertube.session.context.client.osVersion,
+ clientName: parseInt(Constants.CLIENT_NAME_IDS[innertube.session.context.client.clientName as keyof typeof Constants.CLIENT_NAME_IDS]),
+ clientVersion: innertube.session.context.client.clientVersion
+ }
+ });
+
+ sabrAdapter.onMintPoToken(async () => {
+ if (!sessionPoToken) {
+ // For live streams, we must block and wait for the PO token as it's sometimes required for playback to start.
+ // For VODs, we can mint the token in the background to avoid delaying playback, as it's not immediately required.
+ // While BotGuard is pretty darn fast, it still makes a difference in user experience (from my own testing).
+ if (isLive) {
+ await mintSessionPoToken();
+ } else {
+ mintSessionPoToken().then();
+ }
+ }
+
+ return sessionPoToken || coldStartToken || '';
+ });
+
+ sabrAdapter.onReloadPlayerResponse(async (reloadContext) => {
+ console.log('[SABR]', 'Reloading player response...');
+
+ const reloadedInfo = await innertube.actions.execute('/player', {
+ videoId,
+ contentCheckOk: true,
+ racyCheckOk: true,
+ playbackContext: {
+ adPlaybackContext: {
+ pyv: true
+ },
+ contentPlaybackContext: {
+ signatureTimestamp: innertube.session.player?.sts
+ },
+ reloadPlaybackContext: reloadContext
+ }
+ });
+
+ const parsedInfo = new YT.VideoInfo([ reloadedInfo ], innertube.actions, cpn);
+ sabrAdapter.setStreamingURL(innertube.session.player!.decipher(parsedInfo.streaming_data?.server_abr_streaming_url));
+ sabrAdapter.setUstreamerConfig(videoInfo.player_config?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config);
+ });
+
+ sabrAdapter.attach(player);
+
+ if (videoInfo.streaming_data && !isPostLiveDVR && !isLive) {
+ sabrAdapter.setStreamingURL(innertube.session.player!.decipher(videoInfo.streaming_data?.server_abr_streaming_url));
+ sabrAdapter.setUstreamerConfig(videoInfo.player_config?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config);
+ sabrAdapter.setServerAbrFormats(videoInfo.streaming_data.adaptive_formats.map(buildSabrFormat));
+ }
+
+ let manifestUri: string | undefined;
+ if (videoInfo.streaming_data) {
+ if (isLive) {
+ manifestUri = videoInfo.streaming_data.dash_manifest_url ? `${videoInfo.streaming_data.dash_manifest_url}/mpd_version/7` : videoInfo.streaming_data.hls_manifest_url;
+ } else if (isPostLiveDVR) {
+ manifestUri = videoInfo.streaming_data.hls_manifest_url || `${videoInfo.streaming_data.dash_manifest_url}/mpd_version/7`;
+ } else {
+ manifestUri = `data:application/dash+xml;base64,${btoa(await videoInfo.toDash({
+ manifest_options: {
+ is_sabr: true,
+ captions_format: 'vtt',
+ include_thumbnails: false
+ }
+ }))}`;
+ }
+ }
+
+ if (!manifestUri)
+ throw new Error('Could not find a valid manifest URI.');
+
+ await player.load(manifestUri);
+
+ statusElement.textContent = `Playing: ${videoInfo.basic_info.title}`;
+ console.log('[Player]', `Now playing: ${videoInfo.basic_info.title}`);
+ } catch (e: any) {
+ console.error('[Player]', 'Error:', e);
+ statusElement.textContent = `Error: ${e.message}`;
+ }
+}
+
+async function mintSessionPoToken() {
+ if (!sessionPoTokenContentBinding || sessionPoTokenCreationLock) return;
+
+ sessionPoTokenCreationLock = true;
+ try {
+ coldStartToken = botguardService.mintColdStartToken(sessionPoTokenContentBinding);
+ console.info('[Player]', `Cold start token created (Content binding: ${decodeURIComponent(sessionPoTokenContentBinding)})`);
+
+ if (!botguardService.isInitialized()) await botguardService.reinit();
+
+ if (botguardService.integrityTokenBasedMinter) {
+ sessionPoToken = await botguardService.integrityTokenBasedMinter.mintAsWebsafeString(decodeURIComponent(sessionPoTokenContentBinding));
+ console.info('[Player]', `Session PO token created (Content binding: ${decodeURIComponent(sessionPoTokenContentBinding)})`);
+ }
+ } catch (err) {
+ console.error('[Player]', 'Error minting session PO token', err);
+ } finally {
+ sessionPoTokenCreationLock = false;
+ }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ statusElement.textContent = 'Initializing...';
+ loadButton.disabled = true;
+ main().catch((err) => {
+ console.error('Initialization failed:', err);
+ statusElement.textContent = `Initialization failed: ${err.message}`;
+ });
+});
\ No newline at end of file
diff --git a/examples/sabr-shaka-example/tsconfig.json b/examples/sabr-shaka-example/tsconfig.json
new file mode 100644
index 0000000..8b364be
--- /dev/null
+++ b/examples/sabr-shaka-example/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM"],
+ "moduleResolution": "bundler",
+ "strict": true,
+ "sourceMap": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true
+ },
+ "include": ["src"]
+}
\ No newline at end of file