diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6f0cf8a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Run Tests + +on: + push: + paths-ignore: + - "**.md" + branches: + - main + pull_request: + paths-ignore: + - "**.md" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - uses: rui314/setup-mold@v1 + - name: Set up NASM + uses: ilammy/setup-nasm@v1.5.2 + - name: Run tests + run: cargo test diff --git a/Cargo.lock b/Cargo.lock index aff4194..4c4b547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,6 +1541,7 @@ name = "piped-proxy" version = "0.1.0" dependencies = [ "actix-web", + "base64", "blake3", "bytes", "futures-util", @@ -1550,11 +1551,13 @@ dependencies = [ "listenfd", "mimalloc", "once_cell", + "prost", "qstring", "ravif", "regex", "reqwest", "rgb", + "serde_json", "tokio", ] @@ -1616,6 +1619,29 @@ dependencies = [ "syn", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "qstring" version = "0.7.2" diff --git a/Cargo.toml b/Cargo.toml index 73a5eca..8b3f7de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ tokio = { version = "1.37.0", features = ["full"] } actix-web = "4.5.1" reqwest = { version = "0.12.9", features = ["stream", "brotli", "gzip", "socks"], default-features = false } qstring = "0.7.2" +serde_json = "1.0" # Alternate Allocator mimalloc = { version = "0.1.41", optional = true } @@ -28,6 +29,8 @@ bytes = "1.9.0" futures-util = "0.3.30" listenfd = "1.0.1" http = "1.2.0" +prost = "0.13.5" +base64 = "0.22.1" [features] default = ["webp", "mimalloc", "reqwest-rustls", "qhash"] diff --git a/run_sabr_test.sh b/run_sabr_test.sh new file mode 100755 index 0000000..173ee6a --- /dev/null +++ b/run_sabr_test.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}๐Ÿš€ Starting SABR Test Environment${NC}" + +# Function to cleanup background processes +cleanup() { + echo -e "\n${YELLOW}๐Ÿงน Cleaning up...${NC}" + if [ ! -z "$SERVER_PID" ]; then + kill $SERVER_PID 2>/dev/null + echo -e "${YELLOW}Stopped server (PID: $SERVER_PID)${NC}" + fi + + # Show server logs if they exist + if [ -f "server.log" ]; then + echo -e "${BLUE}๐Ÿ“‹ Server logs:${NC}" + tail -20 server.log + fi + + exit 0 +} + +# Set trap to cleanup on script exit +trap cleanup EXIT INT TERM + +# Build and start the Rust server in the background +echo -e "${BLUE}๐Ÿ”จ Building Rust server (debug)...${NC}" +cargo build +if [ $? -ne 0 ]; then + echo -e "${RED}โŒ Failed to build server${NC}" + exit 1 +fi + +echo -e "${BLUE}๐ŸŒ Starting server on port 8080...${NC}" +RUST_LOG=debug ./target/debug/piped-proxy >server.log 2>&1 & +SERVER_PID=$! + +# Wait a moment for server to start +sleep 3 + +# Check if server is running +if ! kill -0 $SERVER_PID 2>/dev/null; then + echo -e "${RED}โŒ Server failed to start${NC}" + if [ -f "server.log" ]; then + echo -e "${RED}Server logs:${NC}" + cat server.log + fi + exit 1 +fi + +echo -e "${GREEN}โœ… Server started (PID: $SERVER_PID)${NC}" + +# Test server connectivity +echo -e "${BLUE}๐Ÿ” Testing server connectivity...${NC}" +if curl -s http://127.0.0.1:8080 >/dev/null; then + echo -e "${GREEN}โœ… Server is responding${NC}" +else + echo -e "${RED}โŒ Server is not responding${NC}" + exit 1 +fi + +# Change to sabr_test directory and run the test +echo -e "${BLUE}๐Ÿงช Running SABR test...${NC}" +cd sabr_test + +# Install dependencies if needed +if [ ! -d "node_modules" ]; then + echo -e "${BLUE}๐Ÿ“ฆ Installing dependencies...${NC}" + bun install +fi + +# Run the test with provided arguments or defaults +echo -e "${GREEN}๐ŸŽฏ Starting SABR test...${NC}" +timeout 30 bun run index.ts --verbose --duration 5 "$@" +TEST_EXIT_CODE=$? + +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}โœ… SABR test completed successfully!${NC}" +elif [ $TEST_EXIT_CODE -eq 124 ]; then + echo -e "${YELLOW}โฐ Test timed out (this might be expected)${NC}" +else + echo -e "${RED}โŒ SABR test failed with exit code: $TEST_EXIT_CODE${NC}" +fi + +echo -e "${GREEN}๐Ÿ Test completed${NC}" diff --git a/sabr_test/.gitignore b/sabr_test/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/sabr_test/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/sabr_test/bun.lock b/sabr_test/bun.lock new file mode 100644 index 0000000..64a2f22 --- /dev/null +++ b/sabr_test/bun.lock @@ -0,0 +1,148 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "sabr_test", + "dependencies": { + "bgutils-js": "^3.2.0", + "cli-progress": "^3.12.0", + "commander": "^14.0.0", + "jsdom": "^26.1.0", + "youtubei.js": "^13.4.0", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/cli-progress": "^3.11.6", + "@types/jsdom": "^21.1.7", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.5.1", "", {}, "sha512-lut4UTvKL8tqtend0UDu7R79/n9jA7Jtxf77RNPbxtmWqfWI4qQ9bTjf7KCS4vfqLmpQbuHr1ciqJumAgJODdw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.0.2", "", {}, "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.0.10", "", { "dependencies": { "@csstools/color-helpers": "^5.0.2", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], + + "@types/cli-progress": ["@types/cli-progress@3.11.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA=="], + + "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], + + "@types/node": ["@types/node@22.15.24", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng=="], + + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + + "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], + + "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "bgutils-js": ["bgutils-js@3.2.0", "", {}, "sha512-CacO15JvxbclbLeCAAm9DETGlLuisRGWpPigoRvNsccSCPEC4pwYwA2g2x/pv7Om/sk79d4ib35V5HHmxPBpDg=="], + + "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], + + "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], + + "commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="], + + "cssstyle": ["cssstyle@4.3.1", "", { "dependencies": { "@asamuzakjp/css-color": "^3.1.2", "rrweb-cssom": "^0.8.0" } }, "sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q=="], + + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "jintr": ["jintr@3.3.1", "", { "dependencies": { "acorn": "^8.8.0" } }, "sha512-nnOzyhf0SLpbWuZ270Omwbj5LcXUkTcZkVnK8/veJXtSZOiATM5gMZMdmzN75FmTyj+NVgrGaPdH12zIJ24oIA=="], + + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nwsapi": ["nwsapi@2.2.20", "", {}, "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "youtubei.js": ["youtubei.js@13.4.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0", "jintr": "^3.3.1", "tslib": "^2.5.0", "undici": "^5.19.1" } }, "sha512-+fmIZU/dWAjsROONrASy1REwVpy6umAPVuoNLr/4iNmZXl84LyBef0n3hrd1Vn9035EuINToGyQcBmifwUEemA=="], + } +} diff --git a/sabr_test/index.ts b/sabr_test/index.ts new file mode 100644 index 0000000..e044ccf --- /dev/null +++ b/sabr_test/index.ts @@ -0,0 +1,375 @@ +#!/usr/bin/env bun + +import { program } from "commander"; +import cliProgress from "cli-progress"; +import { Innertube, UniversalCache } from "youtubei.js"; +import { generateWebPoToken } from "./utils.js"; + +interface SabrTestOptions { + proxy: string; + videoId: string; + duration?: number; + audioItag?: number; + videoItag?: number; + verbose: boolean; +} + +interface FormatId { + itag: number; + lastModified: number; + xtags?: string; +} + +interface BufferedRange { + formatId: FormatId; + startTimeMs: number; + durationMs: number; + startSegmentIndex: number; + endSegmentIndex: number; +} + +interface SabrRequestData { + playerTimeMs: number; + bandwidthEstimate: number; + clientViewportWidth: number; + clientViewportHeight: number; + playbackRate: number; + hasAudio: boolean; + selectedAudioFormatIds: FormatId[]; + selectedVideoFormatIds: FormatId[]; + bufferedRanges: BufferedRange[]; + videoPlaybackUstreamerConfig?: string; + poToken?: string; + playbackCookie?: string; +} + +class SabrTester { + private proxyUrl: string; + private innertube: Innertube | null = null; + private verbose: boolean; + + constructor(proxyUrl: string, verbose: boolean = false) { + this.proxyUrl = proxyUrl; + this.verbose = verbose; + } + + private log(message: string) { + if (this.verbose) { + console.log(`[SABR Test] ${message}`); + } + } + + private error(message: string) { + console.error(`[ERROR] ${message}`); + } + + async initialize() { + this.log("Initializing YouTube client..."); + try { + this.innertube = await Innertube.create({ + cache: new UniversalCache(true), + enable_session_cache: false, + }); + this.log("YouTube client initialized successfully"); + } catch (error) { + throw new Error(`Failed to initialize YouTube client: ${error}`); + } + } + + async getVideoInfo(videoId: string) { + if (!this.innertube) { + throw new Error("YouTube client not initialized"); + } + + this.log(`Fetching video info for: ${videoId}`); + try { + const info = await this.innertube.getBasicInfo(videoId); + + console.log(` +Video Information: + Title: ${info.basic_info.title} + Duration: ${info.basic_info.duration}s + Views: ${info.basic_info.view_count} + Author: ${info.basic_info.author} + Video ID: ${info.basic_info.id} + `); + + return info; + } catch (error) { + throw new Error(`Failed to get video info: ${error}`); + } + } + + async generatePoToken(): Promise { + if (!this.innertube) { + throw new Error("YouTube client not initialized"); + } + + try { + this.log("Generating PoToken..."); + const visitorData = this.innertube.session.context.client.visitorData; + if (!visitorData) { + this.log("No visitor data available, skipping PoToken generation"); + return undefined; + } + + const webPoTokenResult = await generateWebPoToken(visitorData); + this.log(`PoToken generated successfully: ${webPoTokenResult.poToken.substring(0, 20)}...`); + return webPoTokenResult.poToken; + } catch (error) { + this.log(`PoToken generation failed: ${error}`); + return undefined; + } + } + + async testSabrRequest(videoId: string, options: Partial = {}) { + if (!this.innertube) { + throw new Error("YouTube client not initialized"); + } + + const info = await this.getVideoInfo(videoId); + + // Get streaming data + const streamingData = info.streaming_data; + if (!streamingData) { + throw new Error("No streaming data available"); + } + + // Get server ABR streaming URL + const serverAbrStreamingUrl = this.innertube.session.player?.decipher(streamingData.server_abr_streaming_url); + + if (!serverAbrStreamingUrl) { + throw new Error("No server ABR streaming URL found"); + } + + this.log(`Server ABR URL: ${serverAbrStreamingUrl}`); + + // Get video playback ustreamer config + const videoPlaybackUstreamerConfig = + info.page?.[0]?.player_config?.media_common_config?.media_ustreamer_request_config + ?.video_playback_ustreamer_config; + + if (!videoPlaybackUstreamerConfig) { + throw new Error("No video playback ustreamer config found"); + } + + // Generate PoToken + const poToken = await this.generatePoToken(); + + // Find suitable formats + const audioFormat = streamingData.adaptive_formats.find( + (f: any) => + f.mime_type?.includes("audio") && (options.audioItag ? f.itag === options.audioItag : f.itag === 251), + ); + + const videoFormat = streamingData.adaptive_formats.find( + (f: any) => + f.mime_type?.includes("video") && (options.videoItag ? f.itag === options.videoItag : f.itag === 136), + ); + + let finalAudioFormat, finalVideoFormat; + + if (!audioFormat || !videoFormat) { + // If specific formats not found, try to find any audio/video formats + const fallbackAudio = streamingData.adaptive_formats.find((f: any) => f.mime_type?.includes("audio")); + const fallbackVideo = streamingData.adaptive_formats.find((f: any) => f.mime_type?.includes("video")); + + if (!fallbackAudio || !fallbackVideo) { + throw new Error("Could not find suitable audio/video formats"); + } + + console.log(` +Selected Formats (fallback): + Audio: itag=${fallbackAudio.itag}, mime=${fallbackAudio.mime_type} + Video: itag=${fallbackVideo.itag}, mime=${fallbackVideo.mime_type} + `); + + finalAudioFormat = fallbackAudio; + finalVideoFormat = fallbackVideo; + } else { + console.log(` +Selected Formats: + Audio: itag=${audioFormat.itag}, mime=${audioFormat.mime_type} + Video: itag=${videoFormat.itag}, mime=${videoFormat.mime_type} + `); + + finalAudioFormat = audioFormat; + finalVideoFormat = videoFormat; + } + + // Prepare SABR request data with correct structure matching Rust handler + const sabrData: SabrRequestData = { + playerTimeMs: 0, + bandwidthEstimate: 1000000, // 1 Mbps + clientViewportWidth: 1920, + clientViewportHeight: 1080, + playbackRate: 1.0, + hasAudio: true, + selectedAudioFormatIds: [ + { + itag: finalAudioFormat.itag!, + lastModified: parseInt(finalAudioFormat.last_modified_ms || "0"), + xtags: finalAudioFormat.xtags, + }, + ], + selectedVideoFormatIds: [ + { + itag: finalVideoFormat.itag!, + lastModified: parseInt(finalVideoFormat.last_modified_ms || "0"), + xtags: finalVideoFormat.xtags, + }, + ], + bufferedRanges: [], // Empty for initial request + videoPlaybackUstreamerConfig: videoPlaybackUstreamerConfig, // Already base64 encoded + poToken: poToken ? Buffer.from(poToken, "utf-8").toString("base64") : undefined, + }; + + // Test SABR request through proxy + await this.testProxySabrRequest(serverAbrStreamingUrl, sabrData, options.duration || 10); + } + + async testProxySabrRequest(serverAbrUrl: string, sabrData: SabrRequestData, durationSeconds: number) { + const url = new URL(serverAbrUrl); + + // Add sabr parameter to indicate this is a SABR request + url.searchParams.set("sabr", "1"); + + // Replace the host with our proxy + const proxyUrl = new URL(this.proxyUrl); + url.searchParams.set("host", url.host); + + const finalUrl = `${proxyUrl.origin}/videoplayback?${url.searchParams.toString()}`; + + this.log(`Making SABR request to proxy: ${finalUrl}`); + + const progressBar = new cliProgress.SingleBar({ + format: "SABR Test [{bar}] {percentage}% | ETA: {eta}s | {value}/{total}s", + barCompleteChar: "\u2588", + barIncompleteChar: "\u2591", + hideCursor: true, + }); + + progressBar.start(durationSeconds, 0); + + try { + const response = await fetch(finalUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + }, + body: JSON.stringify(sabrData), + }); + + if (!response.ok) { + throw new Error(`SABR request failed: ${response.status} ${response.statusText}`); + } + + this.log(`SABR request successful: ${response.status}`); + this.log(`Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2)}`); + + // Check for playback cookie in response headers + const playbackCookie = response.headers.get("X-Playback-Cookie"); + if (playbackCookie) { + this.log(`Received playback cookie: ${playbackCookie.substring(0, 50)}...`); + } + + // Read response body (this would be the UMP stream) + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("No response body reader available"); + } + + let totalBytes = 0; + let chunks = 0; + const startTime = Date.now(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalBytes += value.length; + chunks++; + + const elapsedSeconds = (Date.now() - startTime) / 1000; + progressBar.update(Math.min(elapsedSeconds, durationSeconds)); + + if (elapsedSeconds >= durationSeconds) { + this.log("Duration limit reached, stopping..."); + break; + } + + // Log first few chunks for debugging + if (chunks <= 3 && this.verbose) { + this.log(`Chunk ${chunks}: ${value.length} bytes`); + this.log( + `First 32 bytes: ${Array.from(value.slice(0, 32)) + .map((b) => (b as number).toString(16).padStart(2, "0")) + .join(" ")}`, + ); + } + } + + progressBar.stop(); + + console.log(` +SABR Test Results: + Total bytes received: ${totalBytes} + Total chunks: ${chunks} + Average chunk size: ${Math.round(totalBytes / chunks)} bytes + Duration: ${((Date.now() - startTime) / 1000).toFixed(2)}s + Throughput: ${(totalBytes / 1024 / 1024 / ((Date.now() - startTime) / 1000)).toFixed(2)} MB/s + `); + + return true; + } catch (error) { + progressBar.stop(); + throw error; + } + } +} + +// CLI setup +program.name("sabr-test").description("Test SABR functionality through piped-proxy").version("1.0.0"); + +program + .option("-p, --proxy ", "Proxy server URL", "http://127.0.0.1:8080") + .option("-v, --video-id ", "YouTube video ID to test with", "eg2g6FPsdLI") + .option("-d, --duration ", "Test duration in seconds", "10") + .option("-a, --audio-itag ", "Audio format itag to use") + .option("--video-itag ", "Video format itag to use") + .option("--verbose", "Enable verbose logging", false); + +program.action(async (options: any) => { + const tester = new SabrTester(options.proxy, options.verbose); + + try { + console.log(` +๐Ÿš€ SABR Proxy Tester +Proxy: ${options.proxy} +Video ID: ${options.videoId} +Duration: ${options.duration}s + `); + + await tester.initialize(); + + // Test SABR functionality + console.log("๐Ÿ”„ Testing SABR functionality..."); + await tester.testSabrRequest(options.videoId, { + duration: parseInt(options.duration), + audioItag: options.audioItag ? parseInt(options.audioItag) : undefined, + videoItag: options.videoItag ? parseInt(options.videoItag) : undefined, + verbose: options.verbose, + proxy: options.proxy, + videoId: options.videoId, + }); + + console.log("โœ… SABR test completed successfully!"); + } catch (error) { + console.error(`โŒ Test failed: ${error}`); + process.exit(1); + } +}); + +// Parse command line arguments +program.parse(); diff --git a/sabr_test/package.json b/sabr_test/package.json new file mode 100644 index 0000000..cff870c --- /dev/null +++ b/sabr_test/package.json @@ -0,0 +1,27 @@ +{ + "name": "sabr_test", + "module": "index.ts", + "type": "module", + "private": true, + "description": "SABR (Server-side Adaptive Bitrate) testing tool for piped-proxy", + "scripts": { + "start": "bun run index.ts", + "test": "bun run index.ts --verbose", + "help": "bun run index.ts --help" + }, + "dependencies": { + "youtubei.js": "^13.4.0", + "commander": "^14.0.0", + "cli-progress": "^3.12.0", + "bgutils-js": "^3.2.0", + "jsdom": "^26.1.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/cli-progress": "^3.11.6", + "@types/jsdom": "^21.1.7" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/sabr_test/tsconfig.json b/sabr_test/tsconfig.json new file mode 100644 index 0000000..9c62f74 --- /dev/null +++ b/sabr_test/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/sabr_test/utils.ts b/sabr_test/utils.ts new file mode 100644 index 0000000..52ee02a --- /dev/null +++ b/sabr_test/utils.ts @@ -0,0 +1,46 @@ +import { BG, type BgConfig } from "bgutils-js"; +import { JSDOM } from "jsdom"; + +export async function generateWebPoToken(visitorData: string) { + const requestKey = "O43z0dpjhgX20SCx4KAo"; + + if (!visitorData) throw new Error("Could not get visitor data"); + + const dom = new JSDOM(); + + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + }); + + const bgConfig: BgConfig = { + fetch: fetch as any, + globalObj: globalThis, + identifier: visitorData, + requestKey, + }; + + const bgChallenge = await BG.Challenge.create(bgConfig); + + if (!bgChallenge) throw new Error("Could not get challenge"); + + const interpreterJavascript = bgChallenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue; + + if (interpreterJavascript) { + new Function(interpreterJavascript)(); + } else throw new Error("Could not load VM"); + + const poTokenResult = await BG.PoToken.generate({ + program: bgChallenge.program, + globalName: bgChallenge.globalName, + bgConfig, + }); + + const placeholderPoToken = BG.PoToken.generateColdStartToken(visitorData); + + return { + visitorData, + placeholderPoToken, + poToken: poTokenResult.poToken, + }; +} diff --git a/src/main.rs b/src/main.rs index daed748..f34dee3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,6 @@ +mod sabr_handler; +mod sabr_parser; +mod sabr_request; mod ump_stream; mod utils; @@ -176,12 +179,22 @@ fn is_header_allowed(header: &str) -> bool { ) } -async fn index(req: HttpRequest) -> Result> { +async fn index(req: HttpRequest, body: Option) -> Result> { if req.method() == actix_web::http::Method::OPTIONS { let mut response = HttpResponse::Ok(); add_headers(&mut response); return Ok(response.finish()); - } else if req.method() != actix_web::http::Method::GET + } + + // parse query string + let mut query = QString::from(req.query_string()); + + // Check if this is a SABR request + let is_sabr = req.path().eq("/videoplayback") && query.get("sabr").is_some(); + + // Allow POST for SABR requests, otherwise only GET and HEAD + if !is_sabr + && req.method() != actix_web::http::Method::GET && req.method() != actix_web::http::Method::HEAD { let mut response = HttpResponse::MethodNotAllowed(); @@ -189,9 +202,6 @@ async fn index(req: HttpRequest) -> Result> { return Ok(response.finish()); } - // parse query string - let mut query = QString::from(req.query_string()); - #[cfg(feature = "qhash")] { use std::collections::BTreeSet; @@ -281,13 +291,20 @@ async fn index(req: HttpRequest) -> Result> { return Err("Domain not allowed".into()); } + // Handle SABR requests for UMP streams + if is_sabr && req.method() == actix_web::http::Method::POST { + let request_body = body.map(|b| String::from_utf8_lossy(&b).to_string()); + return sabr_handler::handle_sabr_request(req, query, host, &CLIENT, request_body).await; + } + let video_playback = req.path().eq("/videoplayback"); if video_playback { if let Some(expiry) = query.get("expire") { let expiry = expiry.parse::()?; let now = SystemTime::now(); - let now = now.duration_since(UNIX_EPOCH) + let now = now + .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs() as i64; if now > expiry { diff --git a/src/sabr_handler.rs b/src/sabr_handler.rs new file mode 100644 index 0000000..fc586b8 --- /dev/null +++ b/src/sabr_handler.rs @@ -0,0 +1,449 @@ +use crate::sabr_parser::SabrParser; +use crate::sabr_request::{ + create_buffered_range, create_format_id, BufferedRange, ClientInfo, FormatId, + SabrRequestBuilder, +}; +use actix_web::{HttpRequest, HttpResponse}; +use base64::{engine::general_purpose, Engine as _}; +use prost::Message; +use qstring::QString; +use reqwest::{Body, Client, Method, Request, Url}; +use serde_json::Value; +use std::error::Error; +use std::str::FromStr; + +#[derive(Debug)] +pub struct SabrRequestData { + pub player_time_ms: i64, + pub bandwidth_estimate: i64, + pub client_viewport_width: i32, + pub client_viewport_height: i32, + pub playback_rate: f32, + pub has_audio: bool, + pub selected_audio_format_ids: Vec, + pub selected_video_format_ids: Vec, + pub buffered_ranges: Vec, + pub video_playback_ustreamer_config: Option>, + pub po_token: Option>, + pub playback_cookie: Option>, +} + +impl SabrRequestData { + pub fn from_json_body(body: &str) -> Result> { + let json: Value = serde_json::from_str(body)?; + + let player_time_ms = json + .get("playerTimeMs") + .and_then(|v| v.as_f64()) + .map(|v| v as i64) + .unwrap_or(0); + + let bandwidth_estimate = json + .get("bandwidthEstimate") + .and_then(|v| v.as_f64()) + .map(|v| v as i64) + .unwrap_or(1000000); // Default 1Mbps + + let client_viewport_width = json + .get("clientViewportWidth") + .and_then(|v| v.as_f64()) + .map(|v| v as i32) + .unwrap_or(1920); + + let client_viewport_height = json + .get("clientViewportHeight") + .and_then(|v| v.as_f64()) + .map(|v| v as i32) + .unwrap_or(1080); + + let playback_rate = json + .get("playbackRate") + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or(1.0); + + let has_audio = json + .get("hasAudio") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + // Parse audio format IDs + let selected_audio_format_ids = json + .get("selectedAudioFormatIds") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| parse_format_id_from_json(item)) + .collect() + }) + .unwrap_or_default(); + + // Parse video format IDs + let selected_video_format_ids = json + .get("selectedVideoFormatIds") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| parse_format_id_from_json(item)) + .collect() + }) + .unwrap_or_default(); + + // Parse buffered ranges + let buffered_ranges = json + .get("bufferedRanges") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| parse_buffered_range_from_json(item)) + .collect() + }) + .unwrap_or_default(); + + // Parse base64 encoded fields + let video_playback_ustreamer_config = json + .get("videoPlaybackUstreamerConfig") + .and_then(|v| v.as_str()) + .and_then(|s| { + eprintln!("videoPlaybackUstreamerConfig string length: {}", s.len()); + eprintln!( + "videoPlaybackUstreamerConfig first 100 chars: {}", + &s[..std::cmp::min(100, s.len())] + ); + match general_purpose::URL_SAFE.decode(s) { + Ok(decoded) => { + eprintln!( + "Successfully decoded videoPlaybackUstreamerConfig: {} bytes", + decoded.len() + ); + Some(decoded) + } + Err(e) => { + eprintln!("Failed to decode videoPlaybackUstreamerConfig: {}", e); + None + } + } + }); + + let po_token = json + .get("poToken") + .and_then(|v| v.as_str()) + .and_then(|s| general_purpose::STANDARD.decode(s).ok()); + + let playback_cookie = json + .get("playbackCookie") + .and_then(|v| v.as_str()) + .and_then(|s| general_purpose::STANDARD.decode(s).ok()); + + Ok(SabrRequestData { + player_time_ms, + bandwidth_estimate, + client_viewport_width, + client_viewport_height, + playback_rate, + has_audio, + selected_audio_format_ids, + selected_video_format_ids, + buffered_ranges, + video_playback_ustreamer_config, + po_token, + playback_cookie, + }) + } +} + +fn parse_format_id_from_json(json: &Value) -> Option { + let itag = json.get("itag")?.as_i64()? as i32; + let last_modified = json.get("lastModified")?.as_u64()?; + let xtags = json + .get("xtags") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Some(create_format_id(itag, last_modified, xtags)) +} + +fn parse_buffered_range_from_json(json: &Value) -> Option { + let format_id = json.get("formatId").and_then(parse_format_id_from_json)?; + let start_time_ms = json.get("startTimeMs")?.as_i64()?; + let duration_ms = json.get("durationMs")?.as_i64()?; + let start_segment_index = json.get("startSegmentIndex")?.as_i64()? as i32; + let end_segment_index = json.get("endSegmentIndex")?.as_i64()? as i32; + + Some(create_buffered_range( + format_id, + start_time_ms, + duration_ms, + start_segment_index, + end_segment_index, + )) +} + +fn get_client_info_from_query(query: &QString) -> ClientInfo { + // Extract client info from query parameters + let client_name = query.get("c").and_then(|c| match c { + "WEB" => Some(1), + "ANDROID" => Some(3), + "IOS" => Some(5), + _ => Some(1), // Default to WEB + }); + + let client_version = query + .get("cver") + .map(|v| v.to_string()) + .unwrap_or_else(|| "2.2040620.05.00".to_string()); + + ClientInfo { + device_make: None, + device_model: None, + client_name, + client_version: Some(client_version), + os_name: Some("Windows".to_string()), + os_version: Some("10.0".to_string()), + accept_language: None, + accept_region: None, + screen_width_points: None, + screen_height_points: None, + screen_width_inches: None, + screen_height_inches: None, + screen_pixel_density: None, + client_form_factor: None, + gmscore_version_code: None, + window_width_points: None, + window_height_points: None, + android_sdk_version: None, + screen_density_float: None, + utc_offset_minutes: None, + time_zone: None, + chipset: None, + } +} + +pub async fn handle_sabr_request( + req: HttpRequest, + mut query: QString, + host: String, + client: &Client, + request_body: Option, +) -> Result> { + // Remove ump parameter before proxying by filtering it out + let filtered_pairs: Vec<_> = query + .into_pairs() + .into_iter() + .filter(|(key, _)| key != "sabr") + .collect(); + query = QString::new(filtered_pairs); + + // Parse request body if provided + let sabr_data = if let Some(body) = request_body { + Some(SabrRequestData::from_json_body(&body)?) + } else { + None + }; + + // Build SABR request + let mut sabr_builder = SabrRequestBuilder::new(); + + // Set client info from query parameters + let client_info = get_client_info_from_query(&query); + sabr_builder = sabr_builder.with_client_info(client_info); + + if let Some(ref data) = sabr_data { + sabr_builder = sabr_builder + .with_player_time_ms(data.player_time_ms) + .with_bandwidth_estimate(data.bandwidth_estimate) + .with_viewport_size(data.client_viewport_width, data.client_viewport_height) + .with_playback_rate(data.playback_rate) + .with_enabled_track_types(if data.has_audio { 1 } else { 2 }) + .with_audio_formats(data.selected_audio_format_ids.clone()) + .with_video_formats(data.selected_video_format_ids.clone()); + + // If no buffered ranges provided, create initial ones like in the working example + let buffered_ranges = if data.buffered_ranges.is_empty() { + // For initial request, buffered ranges should be empty + Vec::new() + } else { + data.buffered_ranges.clone() + }; + + sabr_builder = sabr_builder.with_buffered_ranges(buffered_ranges); + + if let Some(ref config) = data.video_playback_ustreamer_config { + sabr_builder = sabr_builder.with_video_playback_ustreamer_config(config.clone()); + } + + if let Some(ref token) = data.po_token { + sabr_builder = sabr_builder.with_po_token(token.clone()); + } + + if let Some(ref cookie) = data.playback_cookie { + sabr_builder = sabr_builder.with_playback_cookie(cookie.clone()); + } + } + + // Build the protobuf request + let sabr_request = sabr_builder.build(); + let mut encoded_request = Vec::new(); + sabr_request.encode(&mut encoded_request)?; + + // Debug output + eprintln!("SABR request structure:"); + eprintln!( + " client_abr_state: {:?}", + sabr_request.client_abr_state.is_some() + ); + eprintln!( + " selected_format_ids: {} items", + sabr_request.selected_format_ids.len() + ); + eprintln!( + " selected_audio_format_ids: {} items", + sabr_request.selected_audio_format_ids.len() + ); + eprintln!( + " selected_video_format_ids: {} items", + sabr_request.selected_video_format_ids.len() + ); + eprintln!( + " buffered_ranges: {} items", + sabr_request.buffered_ranges.len() + ); + eprintln!( + " video_playback_ustreamer_config: {} bytes", + sabr_request + .video_playback_ustreamer_config + .as_ref() + .map(|v| v.len()) + .unwrap_or(0) + ); + eprintln!( + " streamer_context: {:?}", + sabr_request.streamer_context.is_some() + ); + if let Some(ref ctx) = sabr_request.streamer_context { + eprintln!( + " po_token: {} bytes", + ctx.po_token.as_ref().map(|v| v.len()).unwrap_or(0) + ); + eprintln!( + " playback_cookie: {} bytes", + ctx.playback_cookie.as_ref().map(|v| v.len()).unwrap_or(0) + ); + eprintln!(" client_info: {:?}", ctx.client_info.is_some()); + } + eprintln!(" field1000: {} items", sabr_request.field1000.len()); + + // Print first 100 bytes of the protobuf for debugging + eprintln!( + "First 100 bytes of protobuf: {:?}", + &encoded_request[..std::cmp::min(100, encoded_request.len())] + ); + + // Save protobuf to file for debugging + std::fs::write("my_request_proto.bin", &encoded_request).unwrap_or_else(|e| { + eprintln!("Failed to write protobuf to file: {}", e); + }); + + // Create the URL for the SABR request + let qs = { + let collected = query + .into_pairs() + .into_iter() + .filter(|(key, _)| !matches!(key.as_str(), "host" | "rewrite" | "qhash")) + .collect::>(); + eprintln!("Filtered query parameters: {:?}", collected); + QString::new(collected) + }; + + let mut url = Url::parse(&format!("https://{}{}", host, req.path()))?; + url.set_query(Some(qs.to_string().as_str())); + + // Debug output + eprintln!("SABR request URL: {}", url); + eprintln!("SABR request body length: {}", encoded_request.len()); + + // Create POST request with protobuf body + let mut request = Request::new(Method::POST, url); + request.body_mut().replace(Body::from(encoded_request)); + + // Execute the request + let resp = client.execute(request).await?; + let status = resp.status(); + + if !status.is_success() { + // Get the response body for debugging + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "Failed to read error body".to_string()); + eprintln!("SABR request failed with status: {}", status); + eprintln!("Response body: {}", error_body); + return Err(format!( + "SABR request failed with status: {} - Body: {}", + status, error_body + ) + .into()); + } + + // Parse the SABR response + let response_bytes = resp.bytes().await?; + let mut parser = SabrParser::new(); + let sabr_response = parser.parse_response(&response_bytes)?; + + // Build the response + let mut response_builder = HttpResponse::Ok(); + + // Add CORS headers + response_builder + .append_header(("Access-Control-Allow-Origin", "*")) + .append_header(("Access-Control-Allow-Headers", "*")) + .append_header(("Access-Control-Allow-Methods", "*")) + .append_header(("Access-Control-Max-Age", "1728000")); + + // Add playback cookie to response headers if available + if let Some(cookie) = parser.get_playback_cookie() { + let mut encoded_cookie = Vec::new(); + cookie.encode(&mut encoded_cookie)?; + let encoded_cookie_b64 = general_purpose::STANDARD.encode(encoded_cookie); + response_builder.append_header(("X-Playback-Cookie", encoded_cookie_b64)); + } + + // Add format-specific content ranges + let mut audio_ranges = Vec::new(); + let mut video_ranges = Vec::new(); + + for format in &sabr_response.initialized_formats { + let is_audio = format + .mime_type + .as_ref() + .map(|mime| mime.starts_with("audio/")) + .unwrap_or(false); + + for chunk in &format.media_chunks { + let range = format!("bytes=0-{}", chunk.len() - 1); + if is_audio { + audio_ranges.push(range); + } else { + video_ranges.push(range); + } + } + } + + if !audio_ranges.is_empty() { + response_builder.append_header(("X-Audio-Content-Ranges", audio_ranges.join(","))); + } + + if !video_ranges.is_empty() { + response_builder.append_header(("X-Video-Content-Ranges", video_ranges.join(","))); + } + + // Combine all media chunks into a single response + let mut combined_data = Vec::new(); + for format in &sabr_response.initialized_formats { + for chunk in &format.media_chunks { + combined_data.extend_from_slice(chunk); + } + } + + Ok(response_builder.body(combined_data)) +} diff --git a/src/sabr_parser.rs b/src/sabr_parser.rs new file mode 100644 index 0000000..4ae0161 --- /dev/null +++ b/src/sabr_parser.rs @@ -0,0 +1,811 @@ +use bytes::Bytes; +use prost::Message; +use std::collections::HashMap; +use std::io; + +// SABR Part Types +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PartType { + OnesieHeader = 10, + OnesieData = 11, + MediaHeader = 20, + Media = 21, + MediaEnd = 22, + LiveMetadata = 31, + HostnameChangeHint = 32, + LiveMetadataPromise = 33, + LiveMetadataPromiseCancellation = 34, + NextRequestPolicy = 35, + UstreamerVideoAndFormatData = 36, + FormatSelectionConfig = 37, + UstreamerSelectedMediaStream = 38, + FormatInitializationMetadata = 42, + SabrRedirect = 43, + SabrError = 44, + SabrSeek = 45, + ReloadPlayerResponse = 46, + PlaybackStartPolicy = 47, + AllowedCachedFormats = 48, + StartBwSamplingHint = 49, + PauseBwSamplingHint = 50, + SelectableFormats = 51, + RequestIdentifier = 52, + RequestCancellationPolicy = 53, + OnesiePrefetchRejection = 54, + TimelineContext = 55, + RequestPipelining = 56, + SabrContextUpdate = 57, + StreamProtectionStatus = 58, + SabrContextSendingPolicy = 59, + LawnmowerPolicy = 60, + SabrAck = 61, + EndOfTrack = 62, + CacheLoadPolicy = 63, + LawnmowerMessagingPolicy = 64, + PrewarmConnection = 65, +} + +impl From for PartType { + fn from(value: i32) -> Self { + match value { + 10 => PartType::OnesieHeader, + 11 => PartType::OnesieData, + 20 => PartType::MediaHeader, + 21 => PartType::Media, + 22 => PartType::MediaEnd, + 31 => PartType::LiveMetadata, + 32 => PartType::HostnameChangeHint, + 33 => PartType::LiveMetadataPromise, + 34 => PartType::LiveMetadataPromiseCancellation, + 35 => PartType::NextRequestPolicy, + 36 => PartType::UstreamerVideoAndFormatData, + 37 => PartType::FormatSelectionConfig, + 38 => PartType::UstreamerSelectedMediaStream, + 42 => PartType::FormatInitializationMetadata, + 43 => PartType::SabrRedirect, + 44 => PartType::SabrError, + 45 => PartType::SabrSeek, + 46 => PartType::ReloadPlayerResponse, + 47 => PartType::PlaybackStartPolicy, + 48 => PartType::AllowedCachedFormats, + 49 => PartType::StartBwSamplingHint, + 50 => PartType::PauseBwSamplingHint, + 51 => PartType::SelectableFormats, + 52 => PartType::RequestIdentifier, + 53 => PartType::RequestCancellationPolicy, + 54 => PartType::OnesiePrefetchRejection, + 55 => PartType::TimelineContext, + 56 => PartType::RequestPipelining, + 57 => PartType::SabrContextUpdate, + 58 => PartType::StreamProtectionStatus, + 59 => PartType::SabrContextSendingPolicy, + 60 => PartType::LawnmowerPolicy, + 61 => PartType::SabrAck, + 62 => PartType::EndOfTrack, + 63 => PartType::CacheLoadPolicy, + 64 => PartType::LawnmowerMessagingPolicy, + 65 => PartType::PrewarmConnection, + _ => panic!("Invalid part type: {}", value), + } + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct FormatId { + #[prost(int32, optional, tag = "1")] + pub itag: Option, + #[prost(int64, optional, tag = "2")] + pub last_modified: Option, + #[prost(string, optional, tag = "3")] + pub xtags: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct TimeRange { + #[prost(int64, optional, tag = "1")] + pub start: Option, + #[prost(int64, optional, tag = "2")] + pub end: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct MediaHeader { + #[prost(uint32, optional, tag = "1")] + pub header_id: Option, + #[prost(string, optional, tag = "2")] + pub video_id: Option, + #[prost(int32, optional, tag = "3")] + pub itag: Option, + #[prost(uint64, optional, tag = "4")] + pub lmt: Option, + #[prost(string, optional, tag = "5")] + pub xtags: Option, + #[prost(int64, optional, tag = "6")] + pub start_range: Option, + #[prost(int32, optional, tag = "7")] + pub compression_algorithm: Option, + #[prost(bool, optional, tag = "8")] + pub is_init_seg: Option, + #[prost(int64, optional, tag = "9")] + pub sequence_number: Option, + #[prost(int64, optional, tag = "10")] + pub field10: Option, + #[prost(int64, optional, tag = "11")] + pub start_ms: Option, + #[prost(int64, optional, tag = "12")] + pub duration_ms: Option, + #[prost(message, optional, tag = "13")] + pub format_id: Option, + #[prost(int64, optional, tag = "14")] + pub content_length: Option, + #[prost(message, optional, tag = "15")] + pub time_range: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct SabrError { + #[prost(string, optional, tag = "1")] + pub error_type: Option, + #[prost(int32, optional, tag = "2")] + pub code: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct SabrRedirect { + #[prost(string, optional, tag = "1")] + pub url: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct StreamProtectionStatus { + #[prost(int32, optional, tag = "1")] + pub status: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct PlaybackCookie { + #[prost(int32, optional, tag = "1")] + pub field1: Option, + #[prost(int32, optional, tag = "2")] + pub field2: Option, + #[prost(message, optional, tag = "7")] + pub video_fmt: Option, + #[prost(message, optional, tag = "8")] + pub audio_fmt: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct NextRequestPolicy { + #[prost(int32, optional, tag = "1")] + pub target_audio_readahead_ms: Option, + #[prost(int32, optional, tag = "2")] + pub target_video_readahead_ms: Option, + #[prost(int32, optional, tag = "4")] + pub backoff_time_ms: Option, + #[prost(message, optional, tag = "7")] + pub playback_cookie: Option, + #[prost(string, optional, tag = "8")] + pub video_id: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct FormatInitializationMetadata { + #[prost(message, optional, tag = "1")] + pub format_id: Option, + #[prost(int64, optional, tag = "2")] + pub duration_ms: Option, + #[prost(string, optional, tag = "3")] + pub mime_type: Option, + #[prost(int64, optional, tag = "4")] + pub end_segment_number: Option, +} + +#[derive(Debug, Clone)] +pub struct Sequence { + pub itag: Option, + pub format_id: Option, + pub is_init_segment: Option, + pub duration_ms: Option, + pub start_ms: Option, + pub start_data_range: Option, + pub sequence_number: Option, + pub content_length: Option, + pub time_range: Option, +} + +#[derive(Debug, Clone)] +pub struct InitializedFormat { + pub format_id: FormatId, + pub format_key: String, + pub duration_ms: Option, + pub mime_type: Option, + pub sequence_count: Option, + pub sequence_list: Vec, + pub media_chunks: Vec, +} + +#[derive(Debug, Clone)] +pub struct SabrResponse { + pub initialized_formats: Vec, + pub stream_protection_status: Option, + pub sabr_redirect: Option, + pub sabr_error: Option, + pub next_request_policy: Option, +} + +#[derive(Debug)] +pub struct UmpPart { + pub part_type: PartType, + pub size: usize, + pub data: Bytes, +} + +pub struct SabrParser { + header_id_to_format_key: HashMap, + formats_by_key: HashMap, + initialized_formats: Vec, + playback_cookie: Option, +} + +impl SabrParser { + pub fn new() -> Self { + Self { + header_id_to_format_key: HashMap::new(), + formats_by_key: HashMap::new(), + initialized_formats: Vec::new(), + playback_cookie: None, + } + } + + pub fn parse_response(&mut self, data: &[u8]) -> io::Result { + self.header_id_to_format_key.clear(); + + // Clear sequence lists and media chunks for existing formats + for format in &mut self.initialized_formats { + format.sequence_list.clear(); + format.media_chunks.clear(); + } + + let mut sabr_error: Option = None; + let mut sabr_redirect: Option = None; + let mut stream_protection_status: Option = None; + let mut next_request_policy: Option = None; + + let mut offset = 0; + while offset < data.len() { + match self.parse_ump_part(&data[offset..]) { + Ok((part, consumed)) => { + offset += consumed; + + match part.part_type { + PartType::MediaHeader => { + self.process_media_header(&part.data)?; + } + PartType::Media => { + self.process_media_data(&part.data)?; + } + PartType::MediaEnd => { + self.process_media_end(&part.data)?; + } + PartType::NextRequestPolicy => { + let policy = NextRequestPolicy::decode(&part.data[..]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + // Store playback cookie for use in subsequent requests + if let Some(cookie) = &policy.playback_cookie { + self.playback_cookie = Some(cookie.clone()); + } + + next_request_policy = Some(policy); + } + PartType::FormatInitializationMetadata => { + self.process_format_initialization(&part.data)?; + } + PartType::SabrError => { + sabr_error = Some( + SabrError::decode(&part.data[..]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + ); + } + PartType::SabrRedirect => { + sabr_redirect = Some( + SabrRedirect::decode(&part.data[..]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + ); + } + PartType::StreamProtectionStatus => { + stream_protection_status = Some( + StreamProtectionStatus::decode(&part.data[..]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + ); + } + _ => { + // Ignore other part types for now + } + } + } + Err(_) => break, // Not enough data or invalid part + } + } + + // Update initialized_formats with the processed data from formats_by_key + self.initialized_formats.clear(); + for format in self.formats_by_key.values() { + self.initialized_formats.push(format.clone()); + } + + // Sort by format_key to ensure deterministic ordering + self.initialized_formats + .sort_by(|a, b| a.format_key.cmp(&b.format_key)); + + Ok(SabrResponse { + initialized_formats: self.initialized_formats.clone(), + stream_protection_status, + sabr_redirect, + sabr_error, + next_request_policy, + }) + } + + fn parse_ump_part(&self, data: &[u8]) -> io::Result<(UmpPart, usize)> { + let mut offset = 0; + + let (part_type, consumed) = self.read_varint(&data[offset..])?; + offset += consumed; + + let (part_size, consumed) = self.read_varint(&data[offset..])?; + offset += consumed; + + if data.len() < offset + part_size as usize { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Not enough data for part", + )); + } + + let part_data = Bytes::copy_from_slice(&data[offset..offset + part_size as usize]); + offset += part_size as usize; + + Ok(( + UmpPart { + part_type: PartType::from(part_type), + size: part_size as usize, + data: part_data, + }, + offset, + )) + } + + fn read_varint(&self, data: &[u8]) -> io::Result<(i32, usize)> { + if data.is_empty() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "No data to read varint", + )); + } + + let first_byte = data[0]; + let byte_length = if first_byte < 128 { + 1 + } else if first_byte < 192 { + 2 + } else if first_byte < 224 { + 3 + } else if first_byte < 240 { + 4 + } else { + 5 + }; + + if data.len() < byte_length { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Not enough data for varint", + )); + } + + let value = match byte_length { + 1 => data[0] as i32, + 2 => { + let byte1 = data[0]; + let byte2 = data[1]; + (byte1 as i32 & 0x3f) + 64 * (byte2 as i32) + } + 3 => { + let byte1 = data[0]; + let byte2 = data[1]; + let byte3 = data[2]; + (byte1 as i32 & 0x1f) + 32 * (byte2 as i32 + 256 * byte3 as i32) + } + 4 => { + let byte1 = data[0]; + let byte2 = data[1]; + let byte3 = data[2]; + let byte4 = data[3]; + (byte1 as i32 & 0x0f) + + 16 * (byte2 as i32 + 256 * (byte3 as i32 + 256 * byte4 as i32)) + } + _ => { + let value = u32::from_le_bytes([data[1], data[2], data[3], data[4]]) as i32; + value + } + }; + + Ok((value, byte_length)) + } + + fn process_media_header(&mut self, data: &Bytes) -> io::Result<()> { + let media_header = MediaHeader::decode(&data[..]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + if let Some(format_id) = &media_header.format_id { + let format_key = self.get_format_key(format_id); + + // Register format if not exists + if !self.formats_by_key.contains_key(&format_key) { + self.register_format_from_header(&media_header); + } + + // Save header ID mapping + if let Some(header_id) = media_header.header_id { + self.header_id_to_format_key + .insert(header_id, format_key.clone()); + } + + // Add sequence to format + if let Some(format) = self.formats_by_key.get_mut(&format_key) { + let sequence = Sequence { + itag: media_header.itag, + format_id: media_header.format_id.clone(), + is_init_segment: media_header.is_init_seg, + duration_ms: media_header.duration_ms, + start_ms: media_header.start_ms, + start_data_range: media_header.start_range, + sequence_number: media_header.sequence_number, + content_length: media_header.content_length, + time_range: media_header.time_range.clone(), + }; + format.sequence_list.push(sequence); + } + } + + Ok(()) + } + + fn process_media_data(&mut self, data: &Bytes) -> io::Result<()> { + if data.is_empty() { + return Ok(()); + } + + let header_id = data[0] as u32; + let stream_data = data.slice(1..); + + if let Some(format_key) = self.header_id_to_format_key.get(&header_id) { + if let Some(format) = self.formats_by_key.get_mut(format_key) { + format.media_chunks.push(stream_data); + } + } + + Ok(()) + } + + fn process_media_end(&mut self, data: &Bytes) -> io::Result<()> { + if !data.is_empty() { + let header_id = data[0] as u32; + self.header_id_to_format_key.remove(&header_id); + } + Ok(()) + } + + fn process_format_initialization(&mut self, data: &Bytes) -> io::Result<()> { + let format_init = FormatInitializationMetadata::decode(&data[..]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + self.register_format_from_init(&format_init); + Ok(()) + } + + fn register_format_from_header(&mut self, header: &MediaHeader) { + if let Some(format_id) = &header.format_id { + let format_key = self.get_format_key(format_id); + + let format = InitializedFormat { + format_id: format_id.clone(), + format_key: format_key.clone(), + duration_ms: header.duration_ms, + mime_type: None, + sequence_count: None, + sequence_list: Vec::new(), + media_chunks: Vec::new(), + }; + + self.initialized_formats.push(format.clone()); + self.formats_by_key.insert(format_key.clone(), format); + } + } + + fn register_format_from_init(&mut self, init: &FormatInitializationMetadata) { + if let Some(format_id) = &init.format_id { + let format_key = self.get_format_key(format_id); + + if !self.formats_by_key.contains_key(&format_key) { + let format = InitializedFormat { + format_id: format_id.clone(), + format_key: format_key.clone(), + duration_ms: init.duration_ms, + mime_type: init.mime_type.clone(), + sequence_count: init.end_segment_number, + sequence_list: Vec::new(), + media_chunks: Vec::new(), + }; + + self.initialized_formats.push(format.clone()); + self.formats_by_key.insert(format_key.clone(), format); + } + } + } + + pub fn get_playback_cookie(&self) -> Option<&PlaybackCookie> { + self.playback_cookie.as_ref() + } + + fn get_format_key(&self, format_id: &FormatId) -> String { + format!( + "{};{};", + format_id.itag.unwrap_or(0), + format_id.last_modified.unwrap_or(0) + ) + } +} + +impl Default for SabrParser { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_sabr_parser_with_bin_file() { + // Read the sabr_response.bin file + let data = fs::read("test/sabr_response.bin").expect("Failed to read sabr_response.bin"); + + let mut parser = SabrParser::new(); + match parser.parse_response(&data) { + Ok(response) => { + // Verify the next_request_policy fields + assert!(response.next_request_policy.is_some()); + if let Some(policy) = &response.next_request_policy { + assert_eq!(policy.target_audio_readahead_ms, Some(15016)); + assert_eq!(policy.target_video_readahead_ms, Some(15016)); + assert_eq!(policy.backoff_time_ms, None); + assert_eq!(policy.video_id, None); + + // Verify playback cookie + assert!(policy.playback_cookie.is_some()); + if let Some(cookie) = &policy.playback_cookie { + // Verify video format + assert!(cookie.video_fmt.is_some()); + if let Some(video_fmt) = &cookie.video_fmt { + assert_eq!(video_fmt.itag, Some(136)); + assert_eq!(video_fmt.last_modified, Some(1747754870573293)); + assert_eq!(video_fmt.xtags, None); + } + + // Verify audio format + assert!(cookie.audio_fmt.is_some()); + if let Some(audio_fmt) = &cookie.audio_fmt { + assert_eq!(audio_fmt.itag, Some(251)); + assert_eq!(audio_fmt.last_modified, Some(1747754876286051)); + assert_eq!(audio_fmt.xtags, None); + } + } + } + + // Assert the expected number of initialized formats + assert_eq!(response.initialized_formats.len(), 2); + + // Assert that we have a stream protection status + assert!(response.stream_protection_status.is_some()); + + if let Some(status) = &response.stream_protection_status { + // Assert the expected stream protection status + assert_eq!(status.status, Some(1)); + } + + // Assert specific format details + assert_eq!( + response.initialized_formats[0].format_key, + "136;1747754870573293;" + ); + assert_eq!(response.initialized_formats[0].sequence_list.len(), 4); + assert_eq!(response.initialized_formats[0].media_chunks.len(), 16); + + // Verify specific chunk sizes for format 136 (video) + let video_chunk_sizes: Vec = response.initialized_formats[0] + .media_chunks + .iter() + .map(|c| c.len()) + .collect(); + assert_eq!( + video_chunk_sizes, + vec![ + 32768, 32768, 12433, 32768, 32768, 16936, 32768, 32768, 29449, 6024, 16384, + 16384, 16384, 16384, 16384, 1942 + ] + ); + + // Verify sequence details for format 136 (video) + assert_eq!( + response.initialized_formats[0].sequence_list[0].sequence_number, + Some(26) + ); + assert_eq!( + response.initialized_formats[0].sequence_list[0].start_ms, + Some(123888) + ); + assert_eq!( + response.initialized_formats[0].sequence_list[0].duration_ms, + Some(5008) + ); + + assert_eq!( + response.initialized_formats[0].sequence_list[1].sequence_number, + Some(27) + ); + assert_eq!( + response.initialized_formats[0].sequence_list[1].start_ms, + Some(128896) + ); + assert_eq!( + response.initialized_formats[0].sequence_list[1].duration_ms, + Some(5008) + ); + + assert_eq!( + response.initialized_formats[0].sequence_list[2].sequence_number, + Some(28) + ); + assert_eq!( + response.initialized_formats[0].sequence_list[2].start_ms, + Some(133903) + ); + assert_eq!( + response.initialized_formats[0].sequence_list[2].duration_ms, + Some(5008) + ); + + assert_eq!( + response.initialized_formats[0].sequence_list[3].sequence_number, + Some(29) + ); + assert_eq!( + response.initialized_formats[0].sequence_list[3].start_ms, + Some(138910) + ); + assert_eq!( + response.initialized_formats[0].sequence_list[3].duration_ms, + Some(4974) + ); + + assert_eq!( + response.initialized_formats[1].format_key, + "251;1747754876286051;" + ); + assert_eq!(response.initialized_formats[1].sequence_list.len(), 3); + assert_eq!(response.initialized_formats[1].media_chunks.len(), 15); + + // Verify specific chunk sizes for format 251 (audio) + let audio_chunk_sizes: Vec = response.initialized_formats[1] + .media_chunks + .iter() + .map(|c| c.len()) + .collect(); + assert_eq!( + audio_chunk_sizes, + vec![ + 32768, 32768, 32768, 32768, 2862, 32768, 32768, 32768, 32768, 1553, 32768, + 32768, 32768, 32768, 2552 + ] + ); + + // Verify sequence details for format 251 (audio) + assert_eq!( + response.initialized_formats[1].sequence_list[0].sequence_number, + Some(13) + ); + assert_eq!( + response.initialized_formats[1].sequence_list[0].start_ms, + Some(120001) + ); + assert_eq!( + response.initialized_formats[1].sequence_list[0].duration_ms, + Some(10000) + ); + + assert_eq!( + response.initialized_formats[1].sequence_list[1].sequence_number, + Some(14) + ); + assert_eq!( + response.initialized_formats[1].sequence_list[1].start_ms, + Some(130001) + ); + assert_eq!( + response.initialized_formats[1].sequence_list[1].duration_ms, + Some(10000) + ); + + assert_eq!( + response.initialized_formats[1].sequence_list[2].sequence_number, + Some(15) + ); + assert_eq!( + response.initialized_formats[1].sequence_list[2].start_ms, + Some(140001) + ); + assert_eq!( + response.initialized_formats[1].sequence_list[2].duration_ms, + Some(10000) + ); + + // Verify chunk distribution per sequence (based on our analysis) + // Format 136 should have chunks distributed as: 3 + 3 + 3 + 7 = 16 total + // Format 251 should have chunks distributed as: 5 + 5 + 5 = 15 total + + // Verify that all sequences have the expected itag values + for seq in &response.initialized_formats[0].sequence_list { + assert_eq!(seq.itag, Some(136)); + } + for seq in &response.initialized_formats[1].sequence_list { + assert_eq!(seq.itag, Some(251)); + } + } + Err(e) => { + panic!("Failed to parse SABR response: {}", e); + } + } + } + + #[test] + fn test_varint_parsing() { + let parser = SabrParser::new(); + + // Test single byte varint + let data = [0x08]; // 8 in varint encoding + let (value, consumed) = parser.read_varint(&data).unwrap(); + assert_eq!(value, 8); + assert_eq!(consumed, 1); + + // Test two byte varint - let's use a correct encoding + // For 150: first byte should be >= 128 to indicate 2-byte encoding + // 150 = (first_byte & 0x3f) + 64 * second_byte + // Let's use [0x96, 0x01] which should give us: (0x96 & 0x3f) + 64 * 0x01 = 22 + 64 = 86 + let data = [0x96, 0x01]; + let (value, consumed) = parser.read_varint(&data).unwrap(); + assert_eq!(value, 86); // Corrected expected value + assert_eq!(consumed, 2); + + // Test another two-byte varint for 150 + // 150 = (first_byte & 0x3f) + 64 * second_byte + // We need: 150 = x + 64 * y where x <= 63 + // 150 = 22 + 64 * 2, so first_byte = 128 + 22 = 150, second_byte = 2 + let data = [0x96, 0x02]; // This should give us (0x96 & 0x3f) + 64 * 0x02 = 22 + 128 = 150 + let (value, consumed) = parser.read_varint(&data).unwrap(); + assert_eq!(value, 150); + assert_eq!(consumed, 2); + } + + #[test] + fn test_part_type_conversion() { + assert_eq!(PartType::from(20), PartType::MediaHeader); + assert_eq!(PartType::from(21), PartType::Media); + assert_eq!(PartType::from(35), PartType::NextRequestPolicy); + assert_eq!(PartType::from(43), PartType::SabrRedirect); + assert_eq!(PartType::from(44), PartType::SabrError); + } +} diff --git a/src/sabr_request.rs b/src/sabr_request.rs new file mode 100644 index 0000000..49714c3 --- /dev/null +++ b/src/sabr_request.rs @@ -0,0 +1,548 @@ +use prost::Message; + +// Re-export the FormatId from sabr_parser for consistency +pub use crate::sabr_parser::FormatId; + +#[derive(Clone, PartialEq, Message)] +pub struct ClientInfo { + #[prost(string, optional, tag = "12")] + pub device_make: Option, + #[prost(string, optional, tag = "13")] + pub device_model: Option, + #[prost(int32, optional, tag = "16")] + pub client_name: Option, + #[prost(string, optional, tag = "17")] + pub client_version: Option, + #[prost(string, optional, tag = "18")] + pub os_name: Option, + #[prost(string, optional, tag = "19")] + pub os_version: Option, + #[prost(string, optional, tag = "21")] + pub accept_language: Option, + #[prost(string, optional, tag = "22")] + pub accept_region: Option, + #[prost(int32, optional, tag = "37")] + pub screen_width_points: Option, + #[prost(int32, optional, tag = "38")] + pub screen_height_points: Option, + #[prost(float, optional, tag = "39")] + pub screen_width_inches: Option, + #[prost(float, optional, tag = "40")] + pub screen_height_inches: Option, + #[prost(int32, optional, tag = "41")] + pub screen_pixel_density: Option, + #[prost(int32, optional, tag = "46")] + pub client_form_factor: Option, + #[prost(int32, optional, tag = "50")] + pub gmscore_version_code: Option, + #[prost(int32, optional, tag = "55")] + pub window_width_points: Option, + #[prost(int32, optional, tag = "56")] + pub window_height_points: Option, + #[prost(int32, optional, tag = "64")] + pub android_sdk_version: Option, + #[prost(float, optional, tag = "65")] + pub screen_density_float: Option, + #[prost(int64, optional, tag = "67")] + pub utc_offset_minutes: Option, + #[prost(string, optional, tag = "80")] + pub time_zone: Option, + #[prost(string, optional, tag = "92")] + pub chipset: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct StreamerContext { + #[prost(message, optional, tag = "1")] + pub client_info: Option, + #[prost(bytes = "vec", optional, tag = "2")] + pub po_token: Option>, + #[prost(bytes = "vec", optional, tag = "3")] + pub playback_cookie: Option>, + #[prost(bytes = "vec", optional, tag = "4")] + pub gp: Option>, + #[prost(message, repeated, tag = "5")] + pub field5: Vec, + #[prost(int32, repeated, tag = "6")] + pub field6: Vec, + #[prost(string, optional, tag = "7")] + pub field7: Option, + #[prost(message, optional, tag = "8")] + pub field8: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct TimeRange { + #[prost(int64, optional, tag = "1")] + pub start: Option, + #[prost(int64, optional, tag = "2")] + pub end: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct BufferedRange { + #[prost(message, optional, tag = "1")] + pub format_id: Option, + #[prost(int64, optional, tag = "2")] + pub start_time_ms: Option, + #[prost(int64, optional, tag = "3")] + pub duration_ms: Option, + #[prost(int32, optional, tag = "4")] + pub start_segment_index: Option, + #[prost(int32, optional, tag = "5")] + pub end_segment_index: Option, + #[prost(message, optional, tag = "6")] + pub time_range: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ClientAbrState { + #[prost(int64, optional, tag = "13")] + pub time_since_last_manual_format_selection_ms: Option, + #[prost(sint32, optional, tag = "14")] + pub last_manual_direction: Option, + #[prost(int32, optional, tag = "16")] + pub last_manual_selected_resolution: Option, + #[prost(int32, optional, tag = "17")] + pub detailed_network_type: Option, + #[prost(int32, optional, tag = "18")] + pub client_viewport_width: Option, + #[prost(int32, optional, tag = "19")] + pub client_viewport_height: Option, + #[prost(int64, optional, tag = "20")] + pub client_bitrate_cap_bytes_per_sec: Option, + #[prost(int32, optional, tag = "21")] + pub sticky_resolution: Option, + #[prost(bool, optional, tag = "22")] + pub client_viewport_is_flexible: Option, + #[prost(int64, optional, tag = "23")] + pub bandwidth_estimate: Option, + #[prost(int32, optional, tag = "24")] + pub min_audio_quality: Option, + #[prost(int32, optional, tag = "25")] + pub max_audio_quality: Option, + #[prost(int32, optional, tag = "26")] + pub video_quality_setting: Option, + #[prost(int32, optional, tag = "27")] + pub audio_route: Option, + #[prost(int64, optional, tag = "28")] + pub player_time_ms: Option, + #[prost(int64, optional, tag = "29")] + pub time_since_last_seek: Option, + #[prost(bool, optional, tag = "30")] + pub data_saver_mode: Option, + #[prost(int32, optional, tag = "32")] + pub network_metered_state: Option, + #[prost(int32, optional, tag = "34")] + pub visibility: Option, + #[prost(float, optional, tag = "35")] + pub playback_rate: Option, + #[prost(int64, optional, tag = "36")] + pub elapsed_wall_time_ms: Option, + #[prost(bytes = "vec", optional, tag = "38")] + pub media_capabilities: Option>, + #[prost(int64, optional, tag = "39")] + pub time_since_last_action_ms: Option, + #[prost(int32, optional, tag = "40")] + pub enabled_track_types_bitfield: Option, + #[prost(int32, optional, tag = "43")] + pub max_pacing_rate: Option, + #[prost(int64, optional, tag = "44")] + pub player_state: Option, + #[prost(bool, optional, tag = "46")] + pub drc_enabled: Option, + #[prost(int32, optional, tag = "48")] + pub jda: Option, + #[prost(int32, optional, tag = "50")] + pub qw: Option, + #[prost(int32, optional, tag = "51")] + pub ky: Option, + #[prost(int32, optional, tag = "54")] + pub sabr_report_request_cancellation_info: Option, + #[prost(bool, optional, tag = "56")] + pub l: Option, + #[prost(int64, optional, tag = "57")] + pub g7: Option, + #[prost(bool, optional, tag = "58")] + pub prefer_vp9: Option, + #[prost(int32, optional, tag = "59")] + pub qj: Option, + #[prost(int32, optional, tag = "60")] + pub hx: Option, + #[prost(bool, optional, tag = "61")] + pub is_prefetch: Option, + #[prost(int32, optional, tag = "62")] + pub sabr_support_quality_constraints: Option, + #[prost(bytes = "vec", optional, tag = "63")] + pub sabr_license_constraint: Option>, + #[prost(int32, optional, tag = "64")] + pub allow_proxima_live_latency: Option, + #[prost(int32, optional, tag = "66")] + pub sabr_force_proxima: Option, + #[prost(int32, optional, tag = "67")] + pub tqb: Option, + #[prost(int64, optional, tag = "68")] + pub sabr_force_max_network_interruption_duration_ms: Option, + #[prost(string, optional, tag = "69")] + pub audio_track_id: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct VideoPlaybackAbrRequest { + #[prost(message, optional, tag = "1")] + pub client_abr_state: Option, + #[prost(message, repeated, tag = "2")] + pub selected_format_ids: Vec, + #[prost(message, repeated, tag = "3")] + pub buffered_ranges: Vec, + #[prost(int64, optional, tag = "4")] + pub player_time_ms: Option, + #[prost(bytes = "vec", optional, tag = "5")] + pub video_playback_ustreamer_config: Option>, + #[prost(message, optional, tag = "6")] + pub lo: Option, + #[prost(message, repeated, tag = "16")] + pub selected_audio_format_ids: Vec, + #[prost(message, repeated, tag = "17")] + pub selected_video_format_ids: Vec, + #[prost(message, optional, tag = "19")] + pub streamer_context: Option, + #[prost(message, optional, tag = "21")] + pub field21: Option, + #[prost(int32, optional, tag = "22")] + pub field22: Option, + #[prost(int32, optional, tag = "23")] + pub field23: Option, + #[prost(message, repeated, tag = "1000")] + pub field1000: Vec, +} + +#[derive(Clone, PartialEq, Message)] +pub struct Pqa { + #[prost(message, repeated, tag = "1")] + pub formats: Vec, + #[prost(message, repeated, tag = "2")] + pub ud: Vec, + #[prost(string, optional, tag = "3")] + pub clip_id: Option, +} + +// Default quality constant +const DEFAULT_QUALITY: i32 = 720; // HD720 + +#[derive(Debug, Clone)] +pub struct SabrRequestBuilder { + pub client_abr_state: ClientAbrState, + pub streamer_context: StreamerContext, + pub video_playback_ustreamer_config: Option>, + pub selected_audio_format_ids: Vec, + pub selected_video_format_ids: Vec, + pub buffered_ranges: Vec, +} + +impl SabrRequestBuilder { + pub fn new() -> Self { + Self { + // Minimal defaults matching googlevideo pattern + client_abr_state: ClientAbrState { + last_manual_direction: Some(0), + time_since_last_manual_format_selection_ms: Some(0), + last_manual_selected_resolution: Some(DEFAULT_QUALITY), + sticky_resolution: Some(DEFAULT_QUALITY), + player_time_ms: Some(0), + visibility: Some(0), + enabled_track_types_bitfield: Some(0), + ..Default::default() + }, + streamer_context: StreamerContext { + field5: Vec::new(), + field6: Vec::new(), + field7: None, + field8: None, + gp: None, + playback_cookie: None, + po_token: None, + // Basic client info matching googlevideo + client_info: Some(ClientInfo { + client_name: Some(1), + client_version: Some("2.2040620.05.00".to_string()), + os_name: Some("Windows".to_string()), + os_version: Some("10.0".to_string()), + ..Default::default() + }), + }, + video_playback_ustreamer_config: None, + selected_audio_format_ids: Vec::new(), + selected_video_format_ids: Vec::new(), + buffered_ranges: Vec::new(), + } + } + + pub fn with_client_info(mut self, client_info: ClientInfo) -> Self { + self.streamer_context.client_info = Some(client_info); + self + } + + pub fn with_po_token(mut self, po_token: Vec) -> Self { + self.streamer_context.po_token = Some(po_token); + self + } + + pub fn with_playback_cookie(mut self, playback_cookie: Vec) -> Self { + self.streamer_context.playback_cookie = Some(playback_cookie); + self + } + + pub fn with_video_playback_ustreamer_config(mut self, config: Vec) -> Self { + self.video_playback_ustreamer_config = Some(config); + self + } + + pub fn with_player_time_ms(mut self, time_ms: i64) -> Self { + self.client_abr_state.player_time_ms = Some(time_ms); + self + } + + pub fn with_resolution(mut self, resolution: i32) -> Self { + self.client_abr_state.last_manual_selected_resolution = Some(resolution); + self.client_abr_state.sticky_resolution = Some(resolution); + self + } + + pub fn with_viewport_size(mut self, width: i32, height: i32) -> Self { + self.client_abr_state.client_viewport_width = Some(width); + self.client_abr_state.client_viewport_height = Some(height); + self + } + + pub fn with_bandwidth_estimate(mut self, bandwidth: i64) -> Self { + self.client_abr_state.bandwidth_estimate = Some(bandwidth); + self + } + + pub fn with_audio_formats(mut self, formats: Vec) -> Self { + self.selected_audio_format_ids = formats; + self + } + + pub fn with_video_formats(mut self, formats: Vec) -> Self { + self.selected_video_format_ids = formats; + self + } + + pub fn with_buffered_ranges(mut self, ranges: Vec) -> Self { + self.buffered_ranges = ranges; + self + } + + pub fn with_enabled_track_types(mut self, bitfield: i32) -> Self { + self.client_abr_state.enabled_track_types_bitfield = Some(bitfield); + self + } + + pub fn with_visibility(mut self, visibility: i32) -> Self { + self.client_abr_state.visibility = Some(visibility); + self + } + + pub fn with_playback_rate(mut self, rate: f32) -> Self { + self.client_abr_state.playback_rate = Some(rate); + self + } + + pub fn build(self) -> VideoPlaybackAbrRequest { + // For initial requests, selectedFormatIds should be empty + // It represents previously initialized formats, which don't exist yet + let selected_format_ids = Vec::new(); + + VideoPlaybackAbrRequest { + client_abr_state: Some(self.client_abr_state), + selected_format_ids, + buffered_ranges: self.buffered_ranges, + player_time_ms: Some(0), + video_playback_ustreamer_config: self.video_playback_ustreamer_config, + selected_audio_format_ids: self.selected_audio_format_ids, + selected_video_format_ids: self.selected_video_format_ids, + streamer_context: Some(self.streamer_context), + field22: Some(0), + field23: Some(0), + field1000: Vec::new(), + lo: None, + field21: None, + } + } + + pub fn encode(self) -> Vec { + let request = self.build(); + request.encode_to_vec() + } +} + +impl Default for SabrRequestBuilder { + fn default() -> Self { + Self::new() + } +} + +// Utility functions for creating common format IDs and buffered ranges +pub fn create_format_id(itag: i32, last_modified: u64, xtags: Option) -> FormatId { + FormatId { + itag: Some(itag), + last_modified: Some(last_modified as i64), + xtags, + } +} + +pub fn create_buffered_range( + format_id: FormatId, + start_time_ms: i64, + duration_ms: i64, + start_segment_index: i32, + end_segment_index: i32, +) -> BufferedRange { + BufferedRange { + format_id: Some(format_id), + start_time_ms: Some(start_time_ms), + duration_ms: Some(duration_ms), + start_segment_index: Some(start_segment_index), + end_segment_index: Some(end_segment_index), + time_range: None, + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct LoField4 { + #[prost(int32, optional, tag = "1")] + pub field1: Option, + #[prost(int32, optional, tag = "2")] + pub field2: Option, + #[prost(int32, optional, tag = "3")] + pub field3: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct Lo { + #[prost(message, optional, tag = "1")] + pub format_id: Option, + #[prost(int32, optional, tag = "2")] + pub lj: Option, + #[prost(int32, optional, tag = "3")] + pub sequence_number: Option, + #[prost(message, optional, tag = "4")] + pub field4: Option, + #[prost(int32, optional, tag = "5")] + pub mz: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct OQa { + #[prost(string, repeated, tag = "1")] + pub field1: Vec, + #[prost(bytes = "vec", optional, tag = "2")] + pub field2: Option>, + #[prost(string, optional, tag = "3")] + pub field3: Option, + #[prost(int32, optional, tag = "4")] + pub field4: Option, + #[prost(int32, optional, tag = "5")] + pub field5: Option, + #[prost(string, optional, tag = "6")] + pub field6: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct Hqa { + #[prost(int32, optional, tag = "1")] + pub code: Option, + #[prost(string, optional, tag = "2")] + pub message: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct Gqa { + #[prost(bytes = "vec", optional, tag = "1")] + pub field1: Option>, + #[prost(message, optional, tag = "2")] + pub field2: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct Fqa { + #[prost(int32, optional, tag = "1")] + pub r#type: Option, + #[prost(bytes = "vec", optional, tag = "2")] + pub value: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sabr_request_builder() { + let audio_format = create_format_id(251, 1747754876286051, None); + let video_format = create_format_id(136, 1747754870573293, None); + + let buffered_range = create_buffered_range(audio_format.clone(), 0, 5000, 1, 5); + + let request = SabrRequestBuilder::new() + .with_player_time_ms(1000) + .with_resolution(720) + .with_viewport_size(1280, 720) + .with_bandwidth_estimate(1000000) + .with_audio_formats(vec![audio_format]) + .with_video_formats(vec![video_format]) + .with_buffered_ranges(vec![buffered_range]) + .with_enabled_track_types(0) + .with_visibility(0) + .with_playback_rate(1.0) + .build(); + + assert!(request.client_abr_state.is_some()); + assert!(request.streamer_context.is_some()); + assert_eq!(request.selected_audio_format_ids.len(), 1); + assert_eq!(request.selected_video_format_ids.len(), 1); + assert_eq!(request.buffered_ranges.len(), 1); + + // Test encoding + let encoded = SabrRequestBuilder::new().with_player_time_ms(1000).encode(); + + assert!(!encoded.is_empty()); + } + + #[test] + fn test_format_id_creation() { + let format_id = create_format_id(251, 1747754876286051, Some("test".to_string())); + + assert_eq!(format_id.itag, Some(251)); + assert_eq!(format_id.last_modified, Some(1747754876286051)); + assert_eq!(format_id.xtags, Some("test".to_string())); + } + + #[test] + fn test_default_values() { + let builder = SabrRequestBuilder::new(); + assert_eq!( + builder.client_abr_state.last_manual_selected_resolution, + Some(DEFAULT_QUALITY) + ); + assert_eq!( + builder.client_abr_state.sticky_resolution, + Some(DEFAULT_QUALITY) + ); + assert_eq!(builder.client_abr_state.player_time_ms, Some(0)); + assert_eq!(builder.client_abr_state.visibility, Some(0)); + assert_eq!( + builder.client_abr_state.enabled_track_types_bitfield, + Some(0) + ); + + let client_info = builder.streamer_context.client_info.unwrap(); + assert_eq!(client_info.client_name, Some(1)); + assert_eq!( + client_info.client_version, + Some("2.2040620.05.00".to_string()) + ); + assert_eq!(client_info.os_name, Some("Windows".to_string())); + } +} diff --git a/test/sabr_response.bin b/test/sabr_response.bin new file mode 100644 index 0000000..36d624b Binary files /dev/null and b/test/sabr_response.bin differ