diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f42eaee0..f3c2d718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,12 +34,12 @@ jobs: name: Format Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 components: rustfmt - name: Check formatting @@ -50,12 +50,12 @@ jobs: name: Clippy Lints runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 components: clippy - name: Install build dependencies @@ -77,12 +77,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev lld @@ -101,12 +101,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev lld @@ -123,12 +123,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev lld @@ -154,12 +154,12 @@ jobs: env: SYNCTV_TEST_DOCKER_STARTUP_TIMEOUT_SECS: 600 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install cargo-nextest uses: taiki-e/install-action@nextest @@ -206,12 +206,12 @@ jobs: env: SYNCTV_TEST_DOCKER_STARTUP_TIMEOUT_SECS: 600 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install cargo-nextest uses: taiki-e/install-action@nextest @@ -235,12 +235,12 @@ jobs: name: Security Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install cargo-audit run: make install-cargo-audit @@ -258,12 +258,12 @@ jobs: name: Dependency Policy Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install cargo-deny run: make install-cargo-deny @@ -292,12 +292,12 @@ jobs: name: Unused Dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust nightly uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev lld diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 31646444..e9e19b49 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -19,9 +19,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Dependency Review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@v5 with: fail-on-severity: moderate diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 32801515..035fa872 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -77,12 +77,12 @@ jobs: WEB_UI_EXPORT_DIR: ${{ github.workspace }}/synctv-web-ui/dist steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install Flutter uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 @@ -92,7 +92,7 @@ jobs: cache: true - name: Cache Web build inputs - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.pub-cache diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e2f3b307..0087337f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,10 +25,10 @@ jobs: name: Validate Docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: node-version: 24 cache: npm diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml index 16219e99..dd64c1bc 100644 --- a/.github/workflows/helm-ci.yml +++ b/.github/workflows/helm-ci.yml @@ -39,15 +39,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index 040af02d..307cf92b 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -31,17 +31,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index c8958370..d7458f4f 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -25,12 +25,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2026-09-27 + toolchain: nightly-2026-10-13 - name: Normalize release version id: version @@ -44,7 +44,7 @@ jobs: run: make set-release-version VERSION="${{ steps.version.outputs.value }}" - name: Create release preparation PR - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: commit-message: "release: prepare v${{ steps.version.outputs.value }}" branch: "automation/release-v${{ steps.version.outputs.value }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbeee2fa..30936f5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Create or update GitHub release shell: bash diff --git a/Cargo.lock b/Cargo.lock index 1136095f..84d6da6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,9 +165,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "ammonia" -version = "4.2.0" +version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a" +checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716" dependencies = [ "cssparser", "html5ever", @@ -432,9 +432,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.48" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" dependencies = [ "compression-codecs", "compression-core", @@ -483,7 +483,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -499,9 +499,9 @@ dependencies = [ [[package]] name = "asyncband" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" +checksum = "5161fb43cb8f231f06983cb2b8f9ee9cee14be0b1356d8383398cc0f312800d1" [[package]] name = "atoi" @@ -526,9 +526,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -537,9 +537,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -672,20 +672,19 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.72.1" +version = "0.73.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools 0.13.0", "proc-macro2", "quote", "regex", "rustc-hash", - "shlex 1.3.0", - "syn 2.0.119", + "shlex", + "syn 3.0.9", ] [[package]] @@ -884,7 +883,7 @@ dependencies = [ "cow-utils", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -1086,7 +1085,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1112,7 +1111,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1171,14 +1170,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.5.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -1367,7 +1366,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1432,11 +1431,11 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" dependencies = [ - "brotli 8.0.4", + "brotli 9.0.0", "compression-core", "flate2", "memchr", @@ -1461,9 +1460,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.26" +version = "0.15.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790374c727a4cb20877dd2cc99ce7011cbe03b0b8df083acccd37df0d161304d" +checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404" dependencies = [ "pathdiff", "serde_core", @@ -1851,9 +1850,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", ] @@ -1931,7 +1930,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1953,7 +1952,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2093,9 +2092,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] @@ -2108,7 +2107,7 @@ checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2197,7 +2196,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2337,9 +2336,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" dependencies = [ "serde", ] @@ -2577,9 +2576,9 @@ dependencies = [ [[package]] name = "ffmpeg-sys-next" -version = "9.0.0" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b" +checksum = "c6f932d8c1cede016c65d4f1435f323369c99c74df55143b18676aa10e7603f9" dependencies = [ "bindgen", "cc", @@ -2787,7 +2786,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2971,9 +2970,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -3232,9 +3231,9 @@ dependencies = [ [[package]] name = "html-escape" -version = "0.2.15" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" +checksum = "491194d601b4fa9fac3c9571e014070a0ad1bc3392d1bc10419695196a8b3cc3" [[package]] name = "html5ever" @@ -3259,9 +3258,9 @@ dependencies = [ [[package]] name = "http-acl" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "766815a662bb2a2cea5d2cdfe806177530f8a37a0620f8e8985fa03a8d5ed906" +checksum = "d86fb0b6c928080276b29da0ddd5e9219d714d54e0d9fe181ea21774228f947e" dependencies = [ "bytes", "ipnet", @@ -3340,9 +3339,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -3767,9 +3766,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -3796,9 +3795,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -3808,9 +3807,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] name = "jiff-tzdb-platform" @@ -4016,9 +4015,9 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" dependencies = [ "spin 0.9.9", ] @@ -4054,9 +4053,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libloading" @@ -4113,9 +4112,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -4270,9 +4269,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -4730,9 +4729,9 @@ dependencies = [ [[package]] name = "opendal" -version = "0.59.3" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef16fe6b19b73b49ce39e3e0f4cec27e54789eee5d5f752cb2d26bd3e9449686" +checksum = "3cbd193aec6d62efaff87611dfae52c132aed650bb3cb0c684c0a9394f09a42a" dependencies = [ "opendal-core", "opendal-layer-concurrent-limit", @@ -4743,9 +4742,9 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.59.3" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddff5c648dcb3922c2bec71e0d26e881ddf9c367cb7c9a59ed7f2d39a4f38218" +checksum = "54b5555d753dd6a6383dc0891254b72f3cdc40c011ba0372b560e0ae5aa29f5c" dependencies = [ "anyhow", "asyncband", @@ -4769,9 +4768,9 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.59.3" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56fcee7ac4b4b815d38346ceb4f3a5d7ef18869c369e000cc7f622b51bcfa938" +checksum = "4d6588678f3040a3eab1819364dcbc6a746a8286d1ceb23a70a6614faa01dc6c" dependencies = [ "bytes", "futures", @@ -4783,9 +4782,9 @@ dependencies = [ [[package]] name = "opendal-layer-concurrent-limit" -version = "0.59.3" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b454e68e5f6b331bd079a2ee02ebe05078fc0ddaae6822767ef54ab3c2ac0aa8" +checksum = "db8574309e68bf6d49c5378eaef9d0b3f6786971650b061c69e808254d71cb05" dependencies = [ "asyncband", "futures", @@ -4795,9 +4794,9 @@ dependencies = [ [[package]] name = "opendal-layer-retry" -version = "0.59.3" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2da6d89ef0392177f0410dead4b0f50ae7eed56033e00e6c5d78d618b49194" +checksum = "4aeb54be4940c68609483591934a813f7bdd97d747826783a393914d3139e363" dependencies = [ "backon", "log", @@ -4806,9 +4805,9 @@ dependencies = [ [[package]] name = "opendal-layer-timeout" -version = "0.59.3" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "337e46d134eb08beafea92fbe32fdb96d80750b711fea21b158ed3f5cb7ec40c" +checksum = "1d11d53e67473e8914eacb2e14d586779ae57e1a84759ad25232ef339db77d8a" dependencies = [ "opendal-core", "tokio", @@ -4816,9 +4815,9 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.59.3" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de51cba3b83c8130e211045fd2b9d454c4b73f557ad88b71244c2ab943b2b05" +checksum = "1d83e716c25a81fe2ca701894b8e14b9966acca3812af250e50132accb19da02" dependencies = [ "base64 0.23.1", "bytes", @@ -4880,9 +4879,9 @@ dependencies = [ [[package]] name = "oxc_allocator" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c0b817e08c45c28fbc74511ff2cdf1c9e86b457dd1b6da2c49931493fbb438d" +checksum = "f9aa4f6ef44f0524537e718a3951e95ca7a8ba2bea672a81c52f1366354edb9b" dependencies = [ "allocator-api2", "hashbrown 0.17.1", @@ -4892,9 +4891,9 @@ dependencies = [ [[package]] name = "oxc_ast" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202ffaaa2337e1826b5855b2f2667ef3cfcee44c012148c8640e44dba3bec750" +checksum = "89fe3919efdcf81eee5d98091887365fa1c5c27ae9681da0d0ee4b0b8fc579d5" dependencies = [ "bitflags 2.13.2", "oxc_allocator", @@ -4910,27 +4909,27 @@ dependencies = [ [[package]] name = "oxc_ast_macros" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94bf90674e95f29b2579546cd16334ccd588bb0ef60d490a15878f0c020e093" +checksum = "652d079d347e1744ff21e20b27488aef613d2bf0014c1a04f7cf70b22814db55" dependencies = [ "phf 0.14.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "oxc_data_structures" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d93d5de8ad9fa036f970179eb08d19a3eaad0dd3c794dcd5321d2c25db71f5e" +checksum = "86aa2e513fa7df9a64ef794b6ed16c273dde6ce903a940c7bb7e758de006f550" [[package]] name = "oxc_diagnostics" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eb98dbf92b8597e43d689bed6afb6e508c5ae358d6d8105a1fb25daef7918d5" +checksum = "1e7b8cc3cb29cbf85829b2478fbf24eac3b52ff42723f2b5b748d750129d0038" dependencies = [ "bytecount", "cow-utils", @@ -4946,9 +4945,9 @@ dependencies = [ [[package]] name = "oxc_ecmascript" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899933206d84edb4bb5ccc8027633fa33d30687ba2ca539361cdd5d3ff2c1246" +checksum = "0caa01bfdeaf6d236c2389ac7d2f957c093b1697112394386298a31fa43af79a" dependencies = [ "dragonbox_ecma", "itoa", @@ -4962,9 +4961,9 @@ dependencies = [ [[package]] name = "oxc_estree" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f08888715b49149d49324a99d53a9fa4f9375eb2b96d1694ce098097b410e1a1" +checksum = "316ee8ab64fe4999ebde74ca1122ad0ed257b61619b133f79390d4854e4b6f68" [[package]] name = "oxc_index" @@ -4978,9 +4977,9 @@ dependencies = [ [[package]] name = "oxc_parser" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b461f66fd02878fd12819428ca6d887e967b512f7a0f7c0a9fb6c17b5a9ae694" +checksum = "3317e026b10f295d00c78bc66976c89eff66a35d668e9ca124051fc293805851" dependencies = [ "bitflags 2.13.2", "cow-utils", @@ -5002,9 +5001,9 @@ dependencies = [ [[package]] name = "oxc_regular_expression" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b7b7bd14ea6d12858942fc1ac9f3c1b3c448030ef564bafcc33faca480e293" +checksum = "62315d61cf9085079998525f744ad309529bb749a72c46bc7ffaeed7b0ee6da8" dependencies = [ "bitflags 2.13.2", "oxc_allocator", @@ -5019,9 +5018,9 @@ dependencies = [ [[package]] name = "oxc_span" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a71eea4ea7c4a23cf5a658d8e136541802365121ce6586b59b80cdbab96309" +checksum = "bd97e39f49cecf328dffed6e7f5453c02cc939ce712d078225fc6cb7cce47ac7" dependencies = [ "compact_str", "oxc_allocator", @@ -5032,9 +5031,9 @@ dependencies = [ [[package]] name = "oxc_str" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b91cc4c578ebb315cd4110b90b194ac46d89488cdf7368f50ad8f69d35d216f" +checksum = "715ee1d0b6ec3aae7688b5a70bd72fd8fc67b3c489f8db670efcb970b5ea3e6a" dependencies = [ "compact_str", "hashbrown 0.17.1", @@ -5044,9 +5043,9 @@ dependencies = [ [[package]] name = "oxc_syntax" -version = "0.151.0" +version = "0.154.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719d4ae2bee14590ed2b39e1b7f6b8a9a0e914371f9117b8ebdc0afe7a12905e" +checksum = "527d7d8a87d575889c5de61869b47a252347db0afe30352fa84e3ef008a24f30" dependencies = [ "bitflags 2.13.2", "cow-utils", @@ -5271,19 +5270,21 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +checksum = "b568374ba38b33a6c627141f891faf16902b08d2db26b8ede1bcb0a15b1919fa" dependencies = [ "memchr", + "psm", + "stacker", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +checksum = "b66e184b924cebaaff20ab2256ca52f12332d528a39aa76553b5d96f92aacf7f" dependencies = [ "pest", "pest_generator", @@ -5291,22 +5292,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +checksum = "a87478d267e4de54a626af9754f2f0f58e927aac6ed0575fe89bc05ad6851694" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "pest_meta" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +checksum = "4f986f248b4241ac359b831f6139aaa34e03b08a37b6caf7e201a33f95c869e1" dependencies = [ "pest", ] @@ -5555,9 +5556,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -5848,6 +5849,15 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "ptr_meta" version = "0.3.2" @@ -5865,7 +5875,7 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5891,9 +5901,9 @@ dependencies = [ [[package]] name = "pulldown-cmark-to-cmark" -version = "22.0.2" +version = "22.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b277d0b990daa93e6ada6deb712d447722295e70fe341a7d8285e5395bb8ec3c" +checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" dependencies = [ "pulldown-cmark", ] @@ -5955,7 +5965,7 @@ dependencies = [ "cfg_aliases", "pin-project-lite", "quinn-proto", - "quinn-udp 0.5.15", + "quinn-udp 0.5.16", "rustc-hash", "rustls", "socket2 0.6.5", @@ -5967,9 +5977,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", @@ -5990,9 +6000,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -6004,9 +6014,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca72c3f2792761ab5fa848490694cbc728c7f659669ed4320c4c79c12150966" +checksum = "47091c86514c2e11452cb228dee77037334b99d92a23e4c69aa5982261f39247" dependencies = [ "cfg_aliases", "libc", @@ -6238,7 +6248,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6418,9 +6428,9 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "retina" -version = "0.4.20" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0eb740f743e678e071628ff6bf84ed5ed03df879997cc3d43b5afef64aff93" +checksum = "031b39326ce8613be3273edaac2d132709a08790f73c4dee446a781f6c990e7c" dependencies = [ "base64 0.22.1", "bitstream-io", @@ -6504,7 +6514,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6540,9 +6550,9 @@ dependencies = [ [[package]] name = "rtc" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c5e4a8791908797789d63efd013d6987b1f87cce4255b94343d1fa9c69a81f" +checksum = "4e2ca201de5ccc83aa3c08d49a2f08d7ed0aeeb2052fb1ccc517a8b77a6927ce" dependencies = [ "bytes", "hex", @@ -6576,9 +6586,9 @@ dependencies = [ [[package]] name = "rtc-crypto" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0aa6730d18cbc67b1de0430019f8e4545fd0fddba3c07f38dbbc675dc48829" +checksum = "404ea28682271626739dfb1f3a04e96c596490b948d7629d9e4651dd4ba6aaf3" dependencies = [ "aes 0.8.4", "ccm", @@ -6595,9 +6605,9 @@ dependencies = [ [[package]] name = "rtc-datachannel" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c670c43316afb11bf0b40070717915ff4368666350bad59042c0a89a2a1fd33" +checksum = "8d93dd81014535ff0134c0f19dbccfbae4d5ceb6010cf917c1a6056ea2c0d9bb" dependencies = [ "bytes", "log", @@ -6608,9 +6618,9 @@ dependencies = [ [[package]] name = "rtc-dtls" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05fe638f13e60cf3ef8e1608d0e8d36cfb740cce4514e8d6662887eb6c17ff9" +checksum = "0a2fe5767d6caf61018d52189b3b8e37075799eeb81cd5ab452307c7e7ebd97e" dependencies = [ "bytecheck", "byteorder", @@ -6628,9 +6638,9 @@ dependencies = [ [[package]] name = "rtc-ice" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f437cc58721fdb3f0a2ab8b90660d3c19ea2817bc4e2117b2d80f3f6cfba5f3" +checksum = "099984f1be120b2e5692f232f8cbb82e53f6b4261c5c2eeda5be6223ba1adaa9" dependencies = [ "bytes", "crc", @@ -6647,9 +6657,9 @@ dependencies = [ [[package]] name = "rtc-interceptor" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c8484098eb9f7848152780df3450d704f371e4e930fda443a50b4434da3a3" +checksum = "43e7df85d7aacf6e3f69bb922a6c9f83b2a29461038d06285df7642206df64cf" dependencies = [ "log", "rand 0.10.3", @@ -6661,9 +6671,9 @@ dependencies = [ [[package]] name = "rtc-mdns" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dac42fb41bf3c4833d71314ae1f19145d7e81a5c913e648c9954cab9cca36046" +checksum = "d86ac3cafd411bef81d70aff9d59d9520331b29970f7e361180da99e7a8b5a0d" dependencies = [ "bytes", "log", @@ -6674,9 +6684,9 @@ dependencies = [ [[package]] name = "rtc-media" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79245110e26b3624b1bfd89de8fecc4f94ddb8047dc143f3278ea32e893d05a1" +checksum = "a76840ad2aff52efeb16a613db26c16981841a6cf50907bff950884f00f9d9a9" dependencies = [ "byteorder", "bytes", @@ -6688,9 +6698,9 @@ dependencies = [ [[package]] name = "rtc-rtcp" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "450cc3da3f26eba6c6eb7fc44a16370b1397ebd4cc48fa20328cf33e4b1db647" +checksum = "485aa259bdb68e550363c40c8dc30c1c453ddd80f0cf7224dd2d72dffd5e01c2" dependencies = [ "bytes", "rtc-shared", @@ -6698,9 +6708,9 @@ dependencies = [ [[package]] name = "rtc-rtp" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6a382bc412e79e1a047dc916c435098d9b8d86d6fb76b845948e628df53cfd" +checksum = "2a640f266927139a899a3a93b5cf4aa3626594ee2e5d26202b60930bb42cb445" dependencies = [ "bytes", "memchr", @@ -6711,9 +6721,9 @@ dependencies = [ [[package]] name = "rtc-sctp" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "161e3eb55695d4ce7492a690b6a27dc43bfeee43fd5d4af0423de1ace49456c1" +checksum = "2eb430b379d90e7984306036d1646454a26b300c9c3256009eb8f7e0615e90b9" dependencies = [ "bytes", "crc32c", @@ -6727,9 +6737,9 @@ dependencies = [ [[package]] name = "rtc-sdp" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a59de63dab713911debb05fb5931da889692e50333462e965b783f41f5f90337" +checksum = "cd69b1ea06978e045f374ec19eb34177ee4e4f8349cc7d9c0b183683fa7b2d1f" dependencies = [ "rand 0.10.3", "rtc-shared", @@ -6738,9 +6748,9 @@ dependencies = [ [[package]] name = "rtc-shared" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d32c3e91a0eec814e093f415f5848f5c3c497d8b1140965bf26f81101691ee" +checksum = "1250e81796ac6d3d67bd04ecdb891b8371ff97c8a2ce0a5a1c1321f2e12b657b" dependencies = [ "bitflags 1.3.2", "bytes", @@ -6755,9 +6765,9 @@ dependencies = [ [[package]] name = "rtc-srtp" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2219abff4963802959a9fa8cff370f47ebbcdf92e783e5d8446b440414a8c76e" +checksum = "cbb71f476c84763db0435bc47a598acb31abc5fdc6ca3fc84dcb274ed9b70155" dependencies = [ "byteorder", "bytes", @@ -6769,9 +6779,9 @@ dependencies = [ [[package]] name = "rtc-stun" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f05cfc42b678dc8a2161ec42c5f367af70a129c5e3a2d8ea54227bb220641ed" +checksum = "1e59e5c70b7900f537ded24cfee5fa476e8fdc186b90ced528611e441f4063b4" dependencies = [ "base64 0.22.1", "bytes", @@ -6786,9 +6796,9 @@ dependencies = [ [[package]] name = "rtc-turn" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b78c4f1b2af74058fb0210537cc3ff7f84e6babbc3baf5f392a41e93f103a73" +checksum = "c53078a0696df5a3a5e4947bc550c5e903b59803b252f1d39062d637ebd3d17e" dependencies = [ "bytes", "log", @@ -6899,9 +6909,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -6975,9 +6985,9 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty_aac" -version = "0.5.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ab980ffb66a559565d09dd6727ff2da7d2a39d2e8b6149e48af6908caa99b5" +checksum = "be358888c3ae0cc16d17f50c76b76d8ce903845a97413559cecf38db1ca9c62d" [[package]] name = "rusty_alloc" @@ -7116,9 +7126,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -7290,7 +7300,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -7318,9 +7328,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -7348,14 +7358,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -7386,7 +7396,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -7402,7 +7412,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -7487,12 +7497,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -7851,6 +7855,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -8146,9 +8163,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -8293,7 +8310,6 @@ dependencies = [ "base64 0.23.1", "bytes", "chrono", - "clap", "dashmap 7.0.0-rc2", "futures", "hex", @@ -8343,11 +8359,8 @@ dependencies = [ "tonic", "tonic-health", "tonic-prost", - "tonic-reflection", "tonic-types", - "tonic-web", "tower", - "tower-http 0.7.1", "tracing", "tracing-subscriber", "url", @@ -8372,7 +8385,6 @@ dependencies = [ "base64 0.23.1", "bytes", "chrono", - "clap", "dashmap 7.0.0-rc2", "futures", "hex", @@ -8425,9 +8437,7 @@ dependencies = [ "tonic-prost", "tonic-reflection", "tonic-types", - "tonic-web", "tower", - "tower-http 0.7.1", "tracing", "tracing-subscriber", "url", @@ -8452,7 +8462,6 @@ dependencies = [ "base64 0.23.1", "bytes", "chrono", - "clap", "dashmap 7.0.0-rc2", "futures", "hex", @@ -8507,9 +8516,7 @@ dependencies = [ "tonic", "tonic-health", "tonic-prost", - "tonic-reflection", "tonic-types", - "tonic-web", "tower", "tower-http 0.7.1", "tracing", @@ -8542,7 +8549,6 @@ dependencies = [ "moka", "parking_lot", "prost", - "prost-types", "rand 0.10.3", "redis", "serde", @@ -8688,7 +8694,6 @@ dependencies = [ "axum", "bytes", "chrono", - "clap", "dashmap 7.0.0-rc2", "futures", "hostname", @@ -8747,7 +8752,6 @@ dependencies = [ "synctv-common", "synctv-core", "synctv-proto", - "synctv-proxy", "synctv-realtime", "tokio", "tokio-stream", @@ -8789,7 +8793,6 @@ dependencies = [ "oxc_span", "percent-encoding", "prost", - "prost-types", "quick-xml 0.42.0", "rand 0.10.3", "regex", @@ -9048,7 +9051,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -9181,9 +9184,9 @@ dependencies = [ [[package]] name = "thin-vec" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748" +checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" [[package]] name = "thiserror" @@ -9222,7 +9225,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -9345,9 +9348,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -9367,14 +9370,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -9435,9 +9438,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -9450,9 +9453,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap 2.14.2", "serde_core", @@ -9465,18 +9468,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap 2.14.2", "toml_datetime", @@ -9486,18 +9489,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tonic" @@ -9609,24 +9612,6 @@ dependencies = [ "tonic", ] -[[package]] -name = "tonic-web" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6a1b6319ca4b61a4c0f0c94d439c8f3ed344cca56fe0df40e1fe4be11380b" -dependencies = [ - "base64 0.22.1", - "bytes", - "http", - "http-body", - "pin-project", - "tokio-stream", - "tonic", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tower" version = "0.5.3" @@ -9865,9 +9850,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-bidi" @@ -9883,9 +9868,9 @@ checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -10055,7 +10040,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10083,9 +10068,9 @@ checksum = "552a64d6a0d251ae28bc021838800943cce60c9ec2e60ff331f54e79f696268f" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -10149,9 +10134,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -10214,7 +10199,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -10366,9 +10351,9 @@ dependencies = [ [[package]] name = "webrtc" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3cc7db94f14a20c9401202f7300f38071dcac4446177b40e12dc2df34b2256b" +checksum = "b621e736302bef784948cc8d8099fd0a8fdb5757693c80a344ffbe38c49387d2" dependencies = [ "async-broadcast", "async-channel", @@ -10377,7 +10362,7 @@ dependencies = [ "event-listener", "futures", "log", - "quinn-udp 0.6.2", + "quinn-udp 0.6.3", "rtc", "tokio", ] @@ -10787,9 +10772,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.18" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "yaml-rust2" @@ -10825,30 +10810,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", @@ -10872,15 +10857,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "serde", "zeroize_derive", @@ -10929,7 +10914,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10960,9 +10945,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -10972,27 +10957,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.14.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "8.0.0" +version = "8.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" +checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.1+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index db8d2e59..0f2d635f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,3 @@ -cargo-features = ["trim-paths"] - [workspace] members = [ # SyncTV crates @@ -76,7 +74,7 @@ unnecessary_wraps = "allow" [workspace.dependencies] # Async Runtime -tokio = { version = "=1.53.1", features = [ +tokio = { version = "=1.53.2", features = [ "rt-multi-thread", "macros", "net", @@ -87,7 +85,7 @@ tokio = { version = "=1.53.1", features = [ "signal", "fs", ] } -tokio-util = { version = "=0.7.19", features = ["codec", "io-util", "rt"] } +tokio-util = { version = "=0.7.20", features = ["codec", "io-util", "rt"] } tokio-stream = "=0.1.19" # gRPC & Protobuf @@ -99,7 +97,6 @@ tonic = { version = "=0.14.6", default-features = false, features = [ "channel", "gzip", ] } -tonic-web = "=0.14.6" tonic-reflection = "=0.14.6" tonic-health = "=0.14.6" tonic-types = "=0.14.6" @@ -127,7 +124,7 @@ tower-http = { version = "=0.7.1", features = [ "compression-gzip", "compression-zstd", ] } -hyper = { version = "=1.11.1", default-features = false, features = [ +hyper = { version = "=1.12.0", default-features = false, features = [ "http1", "http2", "server", @@ -176,8 +173,8 @@ webauthn-rs-proto = "=0.6.1-dev" aes-gcm = "=0.11.1" aes = "=0.9.3" cbc = { version = "=0.2.1", features = ["alloc", "block-padding"] } -ammonia = "=4.2.0" -http-acl = "=0.13.0" +ammonia = "=4.2.3" +http-acl = "=0.13.1" hkdf = "=0.13.0" sha1 = "=0.11.0" sha2 = "=0.11.0" @@ -189,11 +186,11 @@ subtle = "=2.6.1" # Serialization serde = { version = "=1.0.229", features = ["derive"] } -serde_json = "=1.0.151" +serde_json = "=1.0.154" quick-xml = "=0.42.0" serde_yaml = "=0.9.34" serde_ignored = "=0.1.14" -toml = "=1.1.6" +toml = "=1.1.8" serde_urlencoded = "=0.7.1" serde_html_form = "=0.4.1" serde_path_to_error = "=0.1.20" @@ -208,7 +205,7 @@ lru = "=0.18.5" # IDs & Crypto nanoid = "=0.5.0" -uuid = { version = "=1.26.1", features = ["v4", "v5", "serde"] } +uuid = { version = "=1.28.0", features = ["v4", "v5", "serde"] } sqids = "=0.4.2" rand = "=0.10.3" rand_08 = { package = "rand", version = "=0.8.6" } @@ -222,27 +219,27 @@ iana-time-zone = "=0.1.65" # Streaming (consolidated xiu crate) synctv-xiu = { path = "synctv-xiu", default-features = false } -webrtc = "=0.21.0" -rtc = "=0.21.0" +webrtc = "=0.21.1" +rtc = "=0.21.1" sdp = "=0.17.2" opus = "=0.4.0" fdk-aac = "=0.8.0" -retina = "=0.4.20" +retina = "=0.4.21" rust_h265 = "=0.1.0" -rusty_aac = { version = "=0.5.0", default-features = false } +rusty_aac = { version = "=1.1.0", default-features = false } rusty_h264 = "=0.16.0" ffmpeg-next = { version = "=9.0.0", default-features = false, features = ["build", "format"] } -ffmpeg-sys-next = { version = "=9.0.0", features = ["build-portable"] } +ffmpeg-sys-next = { version = "=9.0.1", features = ["build-portable"] } # Object Storage -opendal = { version = "=0.59.3", default-features = false, features = [ +opendal = { version = "=0.59.4", default-features = false, features = [ "executors-tokio", "services-s3", "layers-retry", "layers-timeout", "layers-concurrent-limit", ] } -opendal-http-transport-reqwest = { version = "=0.59.3", default-features = false, features = ["rustls-no-provider"] } +opendal-http-transport-reqwest = { version = "=0.59.4", default-features = false, features = ["rustls-no-provider"] } # Logging & Tracing tracing = "=0.1.44" @@ -253,7 +250,7 @@ tracing-appender = "=0.2.5" dotenvy = "=0.15.7" # Configuration -config = { version = "=0.15.26", default-features = false, features = ["yaml", "json", "toml"] } +config = { version = "=0.15.27", default-features = false, features = ["yaml", "json", "toml"] } clap = { version = "=4.6.7", features = ["wrap_help", "derive"] } clap_complete = "=4.6.11" @@ -270,10 +267,10 @@ url = "=2.5.8" percent-encoding = "=2.3.2" urlencoding = "=2.1.3" regex = "=1.13.1" -oxc_allocator = "=0.151.0" -oxc_ast = "=0.151.0" -oxc_parser = "=0.151.0" -oxc_span = "=0.151.0" +oxc_allocator = "=0.154.0" +oxc_ast = "=0.154.0" +oxc_parser = "=0.154.0" +oxc_span = "=0.154.0" boa_engine = { version = "=0.22.0", default-features = false } byteorder = "=1.5.0" http = "=1.5.0" @@ -284,7 +281,7 @@ prometheus = { version = "=0.14.0", features = ["process"] } # Time chrono = { version = "=0.4.45", features = ["serde"] } -html-escape = "=0.2.15" +html-escape = "=0.3.0" rsntp = "=4.1.2" # Kubernetes (in-cluster client for leader election & service discovery) @@ -292,7 +289,7 @@ kube = { version = "=4.2.0", default-features = false, features = ["client", "gz k8s-openapi = { version = "=0.28.0", features = ["latest"] } # TLS -tokio-rustls = { version = "=0.26.5", default-features = false, features = ["tls12", "brotli", "zlib"] } +tokio-rustls = { version = "=0.26.6", default-features = false, features = ["tls12", "brotli", "zlib"] } rustls = { version = "=0.23.45", default-features = false, features = ["std", "tls12", "brotli", "zlib"] } rustls-pemfile = "=2.2.0" hyper-util = { version = "=0.1.21", default-features = false, features = ["tokio"] } @@ -323,7 +320,7 @@ lz4_flex = { version = "=0.14.0", default-features = false, features = [ "safe-encode", "safe-decode", ] } -zstd = { version = "=0.14.0", default-features = false } +zstd = { version = "=0.14.2", default-features = false } image = { version = "=0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif"] } symphonia = { version = "=0.6.1", default-features = false, features = ["opt-simd", "all-formats", "aac", "flac", "mp3", "vorbis"] } diff --git a/deny.toml b/deny.toml index 52f030bb..60002948 100644 --- a/deny.toml +++ b/deny.toml @@ -4,7 +4,7 @@ unsound = "all" yanked = "warn" ignore = [ { id = "RUSTSEC-2023-0071", reason = "rsa is only reached transitively and the advisory has no upstream fixed version yet" }, - { id = "RUSTSEC-2024-0436", reason = "paste remains required by direct macros and transitive cel usage with no maintained drop-in replacement available" }, + { id = "RUSTSEC-2024-0449", reason = "unsafe-libyaml is only reached through the intentionally pinned serde_yaml and has no maintained drop-in replacement" }, { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is still required for PEM parsing and the advisory has no replacement release to upgrade to" }, { id = "RUSTSEC-2025-0141", reason = "bincode 2 is still required by the on-disk cache format and the advisory is maintenance-only with no fixed successor release" }, ] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index f5ada547..5ccc7ad9 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "nightly-2026-09-27" +channel = "nightly-2026-10-13" profile = "minimal" components = ["clippy", "rustfmt"] diff --git a/synctv-api-common/Cargo.toml b/synctv-api-common/Cargo.toml index 6ab73291..3d7acc49 100644 --- a/synctv-api-common/Cargo.toml +++ b/synctv-api-common/Cargo.toml @@ -94,8 +94,6 @@ futures.workspace = true # gRPC tonic.workspace = true tonic-prost.workspace = true -tonic-web.workspace = true -tonic-reflection.workspace = true tonic-health.workspace = true tonic-types.workspace = true prost.workspace = true @@ -109,7 +107,6 @@ pbjson-types.workspace = true # HTTP axum.workspace = true tower.workspace = true -tower-http.workspace = true hyper.workspace = true reqwest.workspace = true http-body-util.workspace = true @@ -157,7 +154,6 @@ chrono.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -clap.workspace = true regex.workspace = true ammonia.workspace = true diff --git a/synctv-api-common/src/impls/messaging/media_swarm_tracker.rs b/synctv-api-common/src/impls/messaging/media_swarm_tracker.rs index ae7c5308..cffe6614 100644 --- a/synctv-api-common/src/impls/messaging/media_swarm_tracker.rs +++ b/synctv-api-common/src/impls/messaging/media_swarm_tracker.rs @@ -28,130 +28,6 @@ pub struct MediaSwarmPeer { pub connection_id: String, } -#[cfg(test)] -mod tests { - use super::*; - - struct HangingRedisRuntime; - - #[async_trait::async_trait] - impl RedisConnectionRuntime for HangingRedisRuntime { - async fn snapshot(&self) -> redis::RedisResult { - std::future::pending().await - } - - fn operation_timeout(&self) -> Duration { - Duration::from_millis(10) - } - } - - #[tokio::test] - async fn local_tracker_bounds_candidates_and_excludes_announcing_connection() { - let tracker = MediaSwarmTracker::new(None, "test:"); - let room_id = RoomId::expect_positive(1); - for index in 0..(MAX_MEDIA_SWARM_PEERS + 5) { - tracker - .announce( - room_id, - format!("usr_{index}"), - format!("conn_{index}"), - "swarm-a", - ) - .await - .expect("announce should succeed"); - } - let peers = tracker - .announce( - room_id, - "usr_current".to_string(), - "conn_current".to_string(), - "swarm-a", - ) - .await - .expect("discovery should succeed"); - - assert_eq!(peers.len(), MAX_MEDIA_SWARM_PEERS); - assert!(peers - .iter() - .all(|peer| peer.connection_id != "conn_current")); - } - - #[tokio::test] - async fn local_tracker_isolates_rooms_and_swarms_and_removes_leaves() { - let tracker = MediaSwarmTracker::new(None, "test:"); - let room_a = RoomId::expect_positive(1); - let room_b = RoomId::expect_positive(2); - tracker - .announce(room_a, "usr_a".into(), "conn_a".into(), "swarm-a") - .await - .expect("first announce should succeed"); - - assert!(tracker - .announce(room_a, "usr_b".into(), "conn_b".into(), "swarm-b") - .await - .expect("other swarm announce should succeed") - .is_empty()); - assert!(tracker - .announce(room_b, "usr_c".into(), "conn_c".into(), "swarm-a") - .await - .expect("other room announce should succeed") - .is_empty()); - tracker - .leave(room_a, "usr_a", "conn_a", "swarm-a") - .await - .expect("leave should succeed"); - assert!(tracker - .announce(room_a, "usr_d".into(), "conn_d".into(), "swarm-a") - .await - .expect("post-leave discovery should succeed") - .is_empty()); - } - - #[tokio::test] - async fn local_membership_lookup_requires_exact_actor_connection_and_swarm() { - let tracker = MediaSwarmTracker::new(None, "test:"); - let room_id = RoomId::expect_positive(1); - tracker - .announce( - room_id, - "usr_a".to_string(), - "conn_a".to_string(), - "swarm-a", - ) - .await - .expect("membership announcement should succeed"); - - assert!(tracker - .contains(room_id, "usr_a", "conn_a", "swarm-a") - .await - .expect("membership lookup should succeed")); - assert!(!tracker - .contains(room_id, "usr_b", "conn_a", "swarm-a") - .await - .expect("actor mismatch lookup should succeed")); - assert!(!tracker - .contains(room_id, "usr_a", "conn_a", "swarm-b") - .await - .expect("swarm mismatch lookup should succeed")); - } - - #[tokio::test] - async fn redis_snapshot_is_bounded_by_runtime_operation_timeout() { - let tracker = MediaSwarmTracker::new(Some(Arc::new(HangingRedisRuntime)), "test:"); - let error = tracker - .announce( - RoomId::expect_positive(1), - "usr_a".to_string(), - "conn_a".to_string(), - "swarm-a", - ) - .await - .expect_err("hanging Redis snapshot should time out"); - - assert!(error.contains("Redis timeout during connection snapshot")); - } -} - #[derive(Clone)] struct LocalMembership { peer: MediaSwarmPeer, @@ -370,3 +246,127 @@ impl MediaSwarmTracker { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + struct HangingRedisRuntime; + + #[async_trait::async_trait] + impl RedisConnectionRuntime for HangingRedisRuntime { + async fn snapshot(&self) -> redis::RedisResult { + std::future::pending().await + } + + fn operation_timeout(&self) -> Duration { + Duration::from_millis(10) + } + } + + #[tokio::test] + async fn local_tracker_bounds_candidates_and_excludes_announcing_connection() { + let tracker = MediaSwarmTracker::new(None, "test:"); + let room_id = RoomId::expect_positive(1); + for index in 0..(MAX_MEDIA_SWARM_PEERS + 5) { + tracker + .announce( + room_id, + format!("usr_{index}"), + format!("conn_{index}"), + "swarm-a", + ) + .await + .expect("announce should succeed"); + } + let peers = tracker + .announce( + room_id, + "usr_current".to_string(), + "conn_current".to_string(), + "swarm-a", + ) + .await + .expect("discovery should succeed"); + + assert_eq!(peers.len(), MAX_MEDIA_SWARM_PEERS); + assert!(peers + .iter() + .all(|peer| peer.connection_id != "conn_current")); + } + + #[tokio::test] + async fn local_tracker_isolates_rooms_and_swarms_and_removes_leaves() { + let tracker = MediaSwarmTracker::new(None, "test:"); + let room_a = RoomId::expect_positive(1); + let room_b = RoomId::expect_positive(2); + tracker + .announce(room_a, "usr_a".into(), "conn_a".into(), "swarm-a") + .await + .expect("first announce should succeed"); + + assert!(tracker + .announce(room_a, "usr_b".into(), "conn_b".into(), "swarm-b") + .await + .expect("other swarm announce should succeed") + .is_empty()); + assert!(tracker + .announce(room_b, "usr_c".into(), "conn_c".into(), "swarm-a") + .await + .expect("other room announce should succeed") + .is_empty()); + tracker + .leave(room_a, "usr_a", "conn_a", "swarm-a") + .await + .expect("leave should succeed"); + assert!(tracker + .announce(room_a, "usr_d".into(), "conn_d".into(), "swarm-a") + .await + .expect("post-leave discovery should succeed") + .is_empty()); + } + + #[tokio::test] + async fn local_membership_lookup_requires_exact_actor_connection_and_swarm() { + let tracker = MediaSwarmTracker::new(None, "test:"); + let room_id = RoomId::expect_positive(1); + tracker + .announce( + room_id, + "usr_a".to_string(), + "conn_a".to_string(), + "swarm-a", + ) + .await + .expect("membership announcement should succeed"); + + assert!(tracker + .contains(room_id, "usr_a", "conn_a", "swarm-a") + .await + .expect("membership lookup should succeed")); + assert!(!tracker + .contains(room_id, "usr_b", "conn_a", "swarm-a") + .await + .expect("actor mismatch lookup should succeed")); + assert!(!tracker + .contains(room_id, "usr_a", "conn_a", "swarm-b") + .await + .expect("swarm mismatch lookup should succeed")); + } + + #[tokio::test] + async fn redis_snapshot_is_bounded_by_runtime_operation_timeout() { + let tracker = MediaSwarmTracker::new(Some(Arc::new(HangingRedisRuntime)), "test:"); + let error = tracker + .announce( + RoomId::expect_positive(1), + "usr_a".to_string(), + "conn_a".to_string(), + "swarm-a", + ) + .await + .expect_err("hanging Redis snapshot should time out"); + + assert!(error.contains("Redis timeout during connection snapshot")); + } +} diff --git a/synctv-api-grpc/Cargo.toml b/synctv-api-grpc/Cargo.toml index 2ae01bf1..08d81d99 100644 --- a/synctv-api-grpc/Cargo.toml +++ b/synctv-api-grpc/Cargo.toml @@ -97,7 +97,6 @@ futures.workspace = true # gRPC tonic.workspace = true tonic-prost.workspace = true -tonic-web.workspace = true tonic-reflection.workspace = true tonic-health.workspace = true tonic-types.workspace = true @@ -112,7 +111,6 @@ pbjson-types.workspace = true # HTTP axum.workspace = true tower.workspace = true -tower-http.workspace = true hyper.workspace = true reqwest.workspace = true http-body-util.workspace = true @@ -160,7 +158,6 @@ chrono.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -clap.workspace = true regex.workspace = true ammonia.workspace = true diff --git a/synctv-api-http/Cargo.toml b/synctv-api-http/Cargo.toml index bc845f24..79c523ea 100644 --- a/synctv-api-http/Cargo.toml +++ b/synctv-api-http/Cargo.toml @@ -105,8 +105,6 @@ futures.workspace = true # gRPC tonic.workspace = true tonic-prost.workspace = true -tonic-web.workspace = true -tonic-reflection.workspace = true tonic-health.workspace = true tonic-types.workspace = true prost.workspace = true @@ -168,7 +166,6 @@ chrono.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -clap.workspace = true regex.workspace = true ammonia.workspace = true serde_html_form.workspace = true diff --git a/synctv-cluster/Cargo.toml b/synctv-cluster/Cargo.toml index 5b486263..3f69fc0b 100644 --- a/synctv-cluster/Cargo.toml +++ b/synctv-cluster/Cargo.toml @@ -54,7 +54,6 @@ futures.workspace = true tonic.workspace = true tonic-prost.workspace = true prost.workspace = true -prost-types.workspace = true # Redis redis.workspace = true diff --git a/synctv-core/src/provider/bilibili.rs b/synctv-core/src/provider/bilibili.rs index 2c191f4e..5a308e16 100644 --- a/synctv-core/src/provider/bilibili.rs +++ b/synctv-core/src/provider/bilibili.rs @@ -4557,1380 +4557,1330 @@ impl DynamicPlaylistProvider for BilibiliProvider { } } -#[cfg(test)] -mod tests { - use super::{ - bilibili_dash_manifest_swarm_id, bilibili_dash_playback_infos, - bilibili_dash_resource_candidates, bilibili_dash_video_codec, bilibili_durl_media, - bilibili_durl_resource_candidates, bilibili_live_danmaku_track, - bilibili_live_playback_infos, bilibili_subtitle_track, bilibili_upstream, - bilibili_vod_danmaku_track, build_bilibili_durl_manifest, build_bilibili_mpd_manifest, - default_bilibili_live_mode, filter_bilibili_dash_manifest, - mark_bilibili_playback_resources, BilibiliDashPlaybackOptions, BilibiliProvider, - BilibiliSmsLoginSession, BilibiliSmsLoginTokenCodec, - }; - use crate::models::media::{ - BilibiliDashAudioStream, BilibiliDashManifest, BilibiliDashVideoStream, - BilibiliDurlSegment, BilibiliPlaybackKind, BilibiliPlaybackMetadata, - PlaybackBilibiliDanmaku, PlaybackBilibiliMedia, PlaybackBilibiliSubtitle, - PlaybackDanmakuProvider, PlaybackMediaProvider, PlaybackMetadata, PlaybackSubtitleProvider, - }; - use crate::models::{BilibiliTarget, PlaylistId, ProviderTarget, RoomId}; - use crate::provider::{ - InMemoryProviderStore, PlaybackAudioCodec, PlaybackClientEnvironment, - PlaybackClientProfile, PlaybackInfo, PlaybackMediaCapability, PlaybackMediaPipeline, - PlaybackMediaTransport, PlaybackResult, PlaybackTransportAction, PlaybackVideoCodec, - ProviderActor, ProviderContext, ProviderStore, ProviderStoreExt, SourceConfig, - VersionedPlayback, CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, +fn map_bilibili_live_danmaku_event( + event: bilibili_upstream::BilibiliLiveDanmakuEvent, +) -> super::BilibiliLiveDanmakuEvent { + let event_type = bilibili_upstream::BilibiliLiveDanmakuEventType::try_from(event.r#type) + .unwrap_or(bilibili_upstream::BilibiliLiveDanmakuEventType::Unspecified); + let kind = match event_type { + bilibili_upstream::BilibiliLiveDanmakuEventType::Unspecified => { + super::BilibiliLiveDanmakuEventKind::Unspecified + } + bilibili_upstream::BilibiliLiveDanmakuEventType::Chat => { + super::BilibiliLiveDanmakuEventKind::Chat + } + bilibili_upstream::BilibiliLiveDanmakuEventType::UserEnter => { + super::BilibiliLiveDanmakuEventKind::UserEnter + } + bilibili_upstream::BilibiliLiveDanmakuEventType::Gift => { + super::BilibiliLiveDanmakuEventKind::Gift + } + bilibili_upstream::BilibiliLiveDanmakuEventType::Heartbeat => { + super::BilibiliLiveDanmakuEventKind::Heartbeat + } + bilibili_upstream::BilibiliLiveDanmakuEventType::Unknown => { + super::BilibiliLiveDanmakuEventKind::Unknown + } }; - use std::collections::HashMap; - use std::sync::Arc; - - type TestResult = anyhow::Result; - - fn provider_ok(result: Result) -> TestResult { - result.map_err(|error| anyhow::anyhow!(error.to_string())) + super::BilibiliLiveDanmakuEvent { + format: event.format, + event_type: event.event_type, + kind, + user: event.user, + message: event.message, + timestamp: event.timestamp, + gift_name: event.gift_name, + gift_count: event.gift_count, + online_count: event.online_count, } +} - fn web_hls_profile() -> PlaybackClientProfile { - PlaybackClientProfile { - profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, - environment: PlaybackClientEnvironment::Web, - supported_video_codecs: vec![PlaybackVideoCodec::H264], - media_capabilities: vec![PlaybackMediaCapability { - transport: PlaybackMediaTransport::Hls, - container: None, - video_codec: Some(PlaybackVideoCodec::H264), - audio_codec: Some(PlaybackAudioCodec::Aac), - pipeline: PlaybackMediaPipeline::MediaSource, - codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()), - }], - supports_custom_http_headers: false, - supports_provider_proxy: true, - ..PlaybackClientProfile::default() - } +#[async_trait] +impl super::BilibiliLiveDanmakuProvider for BilibiliProvider { + async fn watch_bilibili_live_danmaku( + &self, + ctx: &ProviderContext<'_>, + source_config: &MediaSourceConfig, + ) -> Result { + let config = BilibiliSourceConfig::from_media_config(source_config)?; + let BilibiliSourceConfig::Live(config) = config else { + return Err(ProviderError::InvalidConfig( + "Bilibili live danmaku requires a live source config".to_string(), + )); + }; + + let credential_user_id = bilibili_optional_credential_user_id( + ctx, + ProviderCredentialPolicy::from_shared(config.shared), + )?; + let (cookies, _) = resolve_optional_bilibili_cookies(ctx, credential_user_id).await?; + let client = self + .get_client_with_context(ctx.provider_instance_name(), ctx.request_context()) + .await?; + let stream = client + .watch_bilibili_live_danmaku(bilibili_upstream::WatchBilibiliLiveDanmakuReq { + cookies, + room_id: config.room_id, + }) + .await?; + let stream = stream.map(|event| { + event + .map(map_bilibili_live_danmaku_event) + .map_err(ProviderError::from) + }); + Ok(Box::pin(stream)) } +} - fn durl_test_result() -> TestResult { - let media = provider_ok(bilibili_durl_media( - "MP4", - [( - "https://cdn.example/video.mp4?deadline=200".to_string(), - Vec::new(), - 1_000, - )], - "sm3_test_durl_web".to_string(), - ))?; - Ok(PlaybackResult { - playback_infos: HashMap::from([( - "durl".to_string(), - PlaybackInfo { - thumbnail: None, - medias: vec![media], - default_media_index: Some(0), - subtitles: Vec::new(), - default_subtitle_index: None, - danmakus: Vec::new(), - default_danmaku_index: None, +impl BilibiliProvider { + pub async fn get_media_stream( + &self, + store: Option<&std::sync::Arc>, + version: &str, + mode_name: &str, + url_index: usize, + request_context: Option<&super::ExecutionControl>, + range_header: Option<&str>, + ) -> Result { + let versioned = + super::playback_transport::lookup_versioned(store, version, request_context).await?; + let playback_info = versioned + .result + .playback_infos + .get(mode_name) + .ok_or(ProviderError::NotFound)?; + let media = playback_info + .medias + .get(url_index) + .ok_or(ProviderError::NotFound)?; + let url = media.upstream_url().ok_or(ProviderError::NotFound)?; + let headers = media.upstream_headers(); + Ok( + super::playback_transport::PlaybackTransportAction::FetchAndForward { + url: url.to_string(), + headers: if headers.is_empty() { + bilibili_headers() + } else { + headers }, - )]), - default_mode: "durl".to_string(), - provider: crate::models::SourceProvider::Bilibili, - provider_instance_name: None, - duration_seconds: None, - playback_kind: Some(crate::models::PlaybackKind::Regular), - metadata: None, - }) + range_header: range_header.map(ToString::to_string), + proxy_strategy: super::PlaybackResourceProxyStrategy::SliceCache, + }, + ) } - #[test] - fn playback_cache_key_uses_the_codec_route_schema_version() -> TestResult { - let config = super::BilibiliSourceConfig::Video(crate::models::BilibiliVideoSourceConfig { - bvid: Some("BV1test12345".to_string()), - aid: None, - cid: 42, - shared: false, - proxy_mode: crate::models::PlaybackProxyMode::Auto, - }); - - let (key, _) = provider_ok(super::playback_cache_entry(&config, "anonymous", None))?; - - assert!(key.starts_with("playback:v10:video:")); - Ok(()) + pub async fn get_hls_manifest( + &self, + store: Option<&std::sync::Arc>, + version: &str, + mode_name: &str, + url_index: usize, + request_context: Option<&super::ExecutionControl>, + ) -> Result { + let versioned = + super::playback_transport::lookup_versioned(store, version, request_context).await?; + let playback_info = versioned + .result + .playback_infos + .get(mode_name) + .ok_or(ProviderError::NotFound)?; + let media = playback_info + .medias + .get(url_index) + .ok_or(ProviderError::NotFound)?; + if let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { + segments, + .. + }) = &media.provider + { + return Ok( + super::playback_transport::PlaybackTransportAction::M3u8DirectBody { + body: build_bilibili_durl_manifest(segments)?.into_bytes(), + }, + ); + } + if let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { + segments, + .. + }) = &media.provider + { + return Ok( + super::playback_transport::PlaybackTransportAction::M3u8BodyRewrite { + body: build_bilibili_durl_manifest(segments)?.into_bytes(), + }, + ); + } + let url = media.upstream_url().ok_or(ProviderError::NotFound)?; + let headers = media.upstream_headers(); + Ok( + super::playback_transport::PlaybackTransportAction::M3u8Rewrite { + url: url.to_string(), + headers: if headers.is_empty() { + bilibili_headers() + } else { + headers + }, + }, + ) } - #[test] - fn vod_playback_cache_is_partitioned_by_client_codec_capabilities() -> TestResult { - let config = super::BilibiliSourceConfig::Video(crate::models::BilibiliVideoSourceConfig { - bvid: Some("BV1test12345".to_string()), - aid: None, - cid: 42, - shared: false, - proxy_mode: crate::models::PlaybackProxyMode::Auto, - }); - let mut high_profile = web_hls_profile(); - high_profile.media_capabilities[0].transport = PlaybackMediaTransport::Dash; - high_profile.media_capabilities[0].codec_string = Some("avc1.640033,mp4a.40.2".to_string()); - let mut main_profile = high_profile.clone(); - main_profile.media_capabilities[0].codec_string = Some("avc1.64001F,mp4a.40.2".to_string()); - - let (high_key, _) = provider_ok(super::playback_cache_entry( - &config, - "anonymous", - Some(&high_profile), - ))?; - let (main_key, _) = provider_ok(super::playback_cache_entry( - &config, - "anonymous", - Some(&main_profile), - ))?; - - assert_ne!(high_key, main_key); - assert!(!high_key.contains("avc1")); - Ok(()) - } - - #[test] - fn anonymous_access_uses_the_canonical_playback_cache_partition() { - let (cookies, partition) = - BilibiliProvider::anonymous_access().into_cookies_and_partition(); - - assert!(cookies.is_empty()); - assert_eq!(partition, "anonymous"); - } - - #[test] - fn playback_policy_covers_video_pgc_live_and_mixed_dynamic_playlists() -> TestResult { - let media_configs = [ - ( - crate::models::MediaSourceConfig::Bilibili(super::BilibiliSourceConfig::Video( - crate::models::BilibiliVideoSourceConfig { - bvid: Some("BV1test12345".to_string()), - aid: None, - cid: 1, - shared: false, - proxy_mode: crate::models::PlaybackProxyMode::Auto, - }, - )), - "video", - ), - ( - crate::models::MediaSourceConfig::Bilibili(super::BilibiliSourceConfig::Pgc( - crate::models::BilibiliPgcSourceConfig { - epid: 2, - cid: 3, - shared: false, - proxy_mode: crate::models::PlaybackProxyMode::Auto, - }, - )), - "pgc", - ), - ( - crate::models::MediaSourceConfig::Bilibili(super::BilibiliSourceConfig::Live( - crate::models::BilibiliLiveSourceConfig { - room_id: 4, - shared: false, - proxy_mode: crate::models::PlaybackProxyMode::Auto, + pub async fn get_hls_resource( + &self, + store: Option<&std::sync::Arc>, + request: BilibiliHlsResourceRequest<'_>, + request_context: Option<&super::ExecutionControl>, + ) -> Result { + let versioned = + super::playback_transport::lookup_versioned(store, request.version, request_context) + .await?; + let media = versioned + .result + .playback_infos + .get(request.mode_name) + .and_then(|info| info.medias.get(request.media_index)) + .ok_or(ProviderError::NotFound)?; + let headers = media.upstream_headers(); + let headers = if headers.is_empty() { + bilibili_headers() + } else { + headers + }; + if request.is_manifest { + Ok( + super::playback_transport::PlaybackTransportAction::M3u8Rewrite { + url: request.target_url.to_string(), + headers, + }, + ) + } else { + if matches!( + &media.provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { .. }) + ) { + let urls = bilibili_durl_resource_candidates(media, request.target_url) + .ok_or(ProviderError::NotFound)?; + return Ok( + super::playback_transport::PlaybackTransportAction::FetchAndForwardCandidates { + urls, + headers, + range_header: request.range_header.map(ToString::to_string), }, - )), - "live", - ), - ]; - - for (config, variant) in &media_configs { - let policy = provider_ok(super::bilibili_playback_proxy_policy(SourceConfig::media( - config, - )))?; - assert_eq!(policy.auto_policies.len(), 1); - assert_eq!(policy.auto_policies[0].variant, *variant); - assert_eq!( - policy.auto_policies[0].mode, - crate::models::PlaybackProxyMode::Only - ); - assert_eq!( - policy.auto_policies[0].reason, - crate::provider::PlaybackProxyAutoReason::SignedResource - ); - } - - let playlist = crate::models::PlaylistSourceConfig::Bilibili( - crate::models::BilibiliPlaylistSourceConfig { - source: crate::models::BilibiliPlaylistSource::History { - history_type: crate::models::BilibiliHistoryType::All, + ); + } + Ok( + super::playback_transport::PlaybackTransportAction::FetchAndForward { + url: request.target_url.to_string(), + headers, + range_header: request.range_header.map(ToString::to_string), + proxy_strategy: super::PlaybackResourceProxyStrategy::SliceCache, }, - shared: false, - proxy_mode: crate::models::PlaybackProxyMode::Auto, - }, - ); - let policy = provider_ok(super::bilibili_playback_proxy_policy( - SourceConfig::dynamic_playlist(&playlist), - ))?; - assert_eq!( - policy - .auto_policies - .iter() - .map(|policy| policy.variant.as_str()) - .collect::>(), - vec!["video", "pgc", "live"] - ); - assert!(policy.auto_policies.iter().all(|policy| { - policy.mode == crate::models::PlaybackProxyMode::Only - && policy.reason == crate::provider::PlaybackProxyAutoReason::SignedResource - })); - Ok(()) + ) + } } - fn test_sms_login_secret() -> &'static [u8] { - b"test-bilibili-sms-login-secret" + pub async fn get_dash_manifest( + &self, + store: Option<&std::sync::Arc>, + version: &str, + mode_name: &str, + manifest_mode: BilibiliDashManifestMode, + request_context: Option<&super::ExecutionControl>, + ) -> Result { + if mode_name != BILIBILI_DASH_MODE_NAME { + return Err(ProviderError::NotFound); + } + let versioned = + super::playback_transport::lookup_versioned(store, version, request_context).await?; + let dash = dash_manifest_from_metadata(&versioned.result)?; + let body = build_bilibili_mpd_manifest(&dash, |_index, url| url.to_string())?; + Ok(match manifest_mode { + BilibiliDashManifestMode::Direct => { + super::playback_transport::PlaybackTransportAction::DirectBody { + body: body.into_bytes(), + content_type: "application/dash+xml".to_string(), + status: 200, + } + } + BilibiliDashManifestMode::Proxy => { + super::playback_transport::PlaybackTransportAction::MpdBodyRewrite { + body: body.into_bytes(), + source_url: "https://synctv.invalid/bilibili-generated.mpd".to_string(), + } + } + }) } - #[tokio::test] - async fn guest_playback_uses_anonymous_bilibili_access() -> TestResult { - let viewer_id = crate::models::UserId::expect_positive(7); - let context = ProviderContext::new("test", ProviderActor::Guest); - - let (cookies, partition) = - provider_ok(super::resolve_optional_bilibili_cookies(&context, Some(viewer_id)).await)?; - - assert!(cookies.is_empty()); - assert_eq!(partition, "guest-anonymous"); - Ok(()) + pub async fn get_dash_resource( + &self, + store: Option<&std::sync::Arc>, + request: BilibiliDashResourceRequest<'_>, + request_context: Option<&super::ExecutionControl>, + ) -> Result { + let target_url = super::playback_transport::resolve_dash_scope_target( + request.scope_url, + request.resource_path, + request.resource_query, + )?; + if request.is_manifest { + Ok( + super::playback_transport::PlaybackTransportAction::MpdRewrite { + url: target_url, + headers: bilibili_headers(), + }, + ) + } else { + if request.mode_name != BILIBILI_DASH_MODE_NAME { + return Err(ProviderError::NotFound); + } + let versioned = super::playback_transport::lookup_versioned( + store, + request.version, + request_context, + ) + .await?; + let dash = dash_manifest_from_metadata(&versioned.result)?; + let urls = bilibili_dash_resource_candidates( + &dash, + request.scope_url, + request.resource_path, + request.resource_query, + )? + .unwrap_or_else(|| vec![target_url]); + Ok( + super::playback_transport::PlaybackTransportAction::FetchAndForwardCandidates { + urls, + headers: bilibili_headers(), + range_header: request.range_header.map(ToString::to_string), + }, + ) + } } - #[test] - fn guest_public_playlist_has_no_credential_dependency() -> TestResult { - let creator_id = crate::models::UserId::expect_positive(8); - let context = - ProviderContext::new("test", ProviderActor::Guest).with_credential_owner_id(creator_id); - let config = crate::models::PlaylistSourceConfig::Bilibili( - crate::models::BilibiliPlaylistSourceConfig { - source: crate::models::BilibiliPlaylistSource::Popular, - shared: true, - proxy_mode: crate::models::PlaybackProxyMode::Auto, + pub async fn get_subtitle( + &self, + store: Option<&std::sync::Arc>, + version: &str, + mode_name: &str, + subtitle_index: usize, + request_context: Option<&super::ExecutionControl>, + ) -> Result { + let versioned = + super::playback_transport::lookup_versioned(store, version, request_context).await?; + let playback_info = versioned + .result + .playback_infos + .get(mode_name) + .ok_or(ProviderError::NotFound)?; + let subtitle = playback_info + .subtitles + .get(subtitle_index) + .ok_or(ProviderError::NotFound)?; + let media_headers = playback_info + .medias + .first() + .map_or_else(HashMap::new, PlaybackMedia::upstream_headers); + let headers = super::subtitle_headers_for_proxy(&media_headers, subtitle); + Ok( + super::playback_transport::PlaybackTransportAction::FetchAndForward { + url: subtitle.upstream_url().to_string(), + headers: if headers.is_empty() { + bilibili_headers() + } else { + headers + }, + range_header: None, + proxy_strategy: super::PlaybackResourceProxyStrategy::FullResponseCache, }, - ); - - let dependencies = provider_ok(super::bilibili_credential_dependencies( - &context, - super::SourceConfig::dynamic_playlist(&config), - ))?; - assert!(dependencies.is_empty()); - Ok(()) + ) } - #[test] - fn guest_private_playlist_uses_creator_credential() -> TestResult { - let creator_id = crate::models::UserId::expect_positive(9); - let context = - ProviderContext::new("test", ProviderActor::Guest).with_credential_owner_id(creator_id); - let config = crate::models::PlaylistSourceConfig::Bilibili( - crate::models::BilibiliPlaylistSourceConfig { - source: crate::models::BilibiliPlaylistSource::WatchLater, - shared: false, - proxy_mode: crate::models::PlaybackProxyMode::Auto, + pub async fn get_danmaku_file( + &self, + store: Option<&std::sync::Arc>, + version: &str, + danmaku_index: usize, + request_context: Option<&super::ExecutionControl>, + ) -> Result { + let versioned = + super::playback_transport::lookup_versioned(store, version, request_context).await?; + let playback_info = versioned + .result + .playback_infos + .get(&versioned.result.default_mode) + .ok_or(ProviderError::NotFound)?; + let danmaku = playback_info + .danmakus + .get(danmaku_index) + .ok_or(ProviderError::NotFound)?; + let url = danmaku.upstream_url().ok_or(ProviderError::NotFound)?; + let headers = danmaku.upstream_headers(); + Ok( + super::playback_transport::PlaybackTransportAction::FetchAndForward { + url: url.to_string(), + headers: if headers.is_empty() { + bilibili_headers() + } else { + headers + }, + range_header: None, + proxy_strategy: super::PlaybackResourceProxyStrategy::FullResponseCache, }, - ); - - let dependencies = provider_ok(super::bilibili_credential_dependencies( - &context, - super::SourceConfig::dynamic_playlist(&config), - ))?; - assert_eq!(dependencies.len(), 1); - assert_eq!(dependencies[0].user_id, creator_id); - assert!(dependencies[0].requirement.is_required()); - Ok(()) + ) } +} - #[test] - fn sms_login_session_token_decodes_across_codecs() -> TestResult { - let codec_one = provider_ok(BilibiliSmsLoginTokenCodec::derive_from( - test_sms_login_secret(), - ))?; - let codec_two = provider_ok(BilibiliSmsLoginTokenCodec::derive_from( - test_sms_login_secret(), - ))?; - let session = BilibiliSmsLoginSession { - token: "captcha-token".to_string(), - challenge: "captcha-challenge".to_string(), - phone: Some("13800000000".to_string()), - captcha_key: Some("captcha-key".to_string()), - instance_name: Some("bilibili_remote".to_string()), - expires_at: crate::SystemClock.now().timestamp() + 60, - }; +use super::bilibili_headers; - let encoded = provider_ok(codec_one.encode(&session))?; - assert!( - !encoded.contains("captcha-token") - && !encoded.contains("captcha-challenge") - && !encoded.contains("captcha-key") - && !encoded.contains("13800000000"), - "session token must keep Bilibili SMS login secrets and phone number encrypted" - ); - let decoded = provider_ok(codec_two.decode(&encoded))?; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BilibiliDashManifestMode { + Direct, + Proxy, +} - assert_eq!(decoded.token, session.token); - assert_eq!(decoded.challenge, session.challenge); - assert_eq!(decoded.phone, session.phone); - assert_eq!(decoded.captcha_key, session.captcha_key); - assert_eq!(decoded.instance_name, session.instance_name); - Ok(()) +fn playback_media( + name: String, + format: String, + expires_at: Option, + p2p_swarm_id: Option, + provider: PlaybackMediaProvider, +) -> PlaybackMedia { + PlaybackMedia { + name, + format, + expire_at: expires_at.and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, 0)), + metadata: None, + p2p_swarm_id, + provider, } +} - #[test] - fn sms_login_session_token_rejects_tampering_and_expiry() -> TestResult { - let codec = provider_ok(BilibiliSmsLoginTokenCodec::derive_from( - test_sms_login_secret(), - ))?; - let valid = BilibiliSmsLoginSession { - token: "captcha-token".to_string(), - challenge: "captcha-challenge".to_string(), - phone: None, - captcha_key: None, - instance_name: None, - expires_at: crate::SystemClock.now().timestamp() + 60, - }; - let expired = BilibiliSmsLoginSession { - expires_at: crate::SystemClock.now().timestamp() - 1, - ..valid.clone() - }; - - let encoded = provider_ok(codec.encode(&valid))?; - let mut tampered = encoded.clone(); - tampered.push('x'); +fn bilibili_url_expiration_timestamp(value: &str) -> Option { + let bilibili_expiration = url::Url::parse(value).ok().and_then(|url| { + url.query_pairs() + .filter(|(key, _)| { + key.eq_ignore_ascii_case("deadline") || key.eq_ignore_ascii_case("expires") + }) + .filter_map(|(_, value)| value.parse::().ok()) + .filter(|expires_at| *expires_at > 0) + .min() + }); + super::url_expiration_timestamp(value) + .into_iter() + .chain(bilibili_expiration) + .min() +} - assert!(codec.decode(&tampered).is_err()); - let expired_token = provider_ok(codec.encode(&expired))?; - assert!(codec.decode(&expired_token).is_err()); - Ok(()) +fn bilibili_subtitle_track( + provider_instance_name: Option<&str>, + content_descriptor: &str, + name: &str, + url: String, +) -> PlaybackSubtitle { + let expire_at = bilibili_url_expiration_timestamp(&url) + .and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, 0)); + let url_identity = url::Url::parse(&url).map_or_else( + |_| url.split('?').next().unwrap_or_default().to_string(), + |url| { + format!( + "{}://{}{}", + url.scheme(), + url.host_str().unwrap_or_default(), + url.path() + ) + }, + ); + PlaybackSubtitle { + language: name.to_string(), + name: name.to_string(), + format: "json".to_string(), + p2p_swarm_id: Some(super::provider_p2p_swarm_id( + BilibiliProvider::NAME, + provider_instance_name, + "subtitle", + &format!("{content_descriptor}:track:{name}:url:{url_identity}"), + )), + provider: PlaybackSubtitleProvider::Bilibili(PlaybackBilibiliSubtitle::Direct { + url, + headers: bilibili_headers(), + expire_at, + }), } +} - #[test] - fn history_cursor_round_trips_as_an_opaque_cursor() -> TestResult { - let encoded = provider_ok(BilibiliProvider::encode_history_cursor(Some( - super::bilibili_upstream::HistoryCursor { - max: 77, - view_at: 123_456, - business: "archive".to_string(), - }, - )))? - .ok_or_else(|| anyhow::anyhow!("encoded cursor should exist"))?; - assert!(!encoded.contains("archive")); - - let decoded = provider_ok(BilibiliProvider::decode_history_cursor(Some(&encoded)))? - .ok_or_else(|| anyhow::anyhow!("decoded cursor should exist"))?; - assert_eq!(decoded.max, 77); - assert_eq!(decoded.view_at, 123_456); - assert_eq!(decoded.business, "archive"); - Ok(()) - } +fn dash_manifest_expiration(dash: &BilibiliDashManifest) -> Option { + dash.video_streams + .iter() + .flat_map(|stream| std::iter::once(&stream.base_url).chain(&stream.backup_urls)) + .chain( + dash.audio_streams + .iter() + .flat_map(|stream| std::iter::once(&stream.base_url).chain(&stream.backup_urls)), + ) + .filter_map(|url| bilibili_url_expiration_timestamp(url)) + .min() +} - #[test] - fn dynamic_items_keep_provider_owned_metadata() { - let target = ProviderTarget::Bilibili(BilibiliTarget::Live { - room_id: 21_292_831, - }); - let Some(PlaybackMetadata::Bilibili(metadata)) = - BilibiliProvider::directory_metadata(&target) - else { - panic!("expected Bilibili metadata"); - }; - assert_eq!(metadata.kind, BilibiliPlaybackKind::Live); - assert_eq!(metadata.room_id, Some(21_292_831)); - } +fn bilibili_video_content_descriptor(bvid: Option<&str>, aid: u64, cid: u64) -> String { + bvid.map_or_else( + || format!("video:aid:{aid}:cid:{cid}"), + |bvid| format!("video:bvid:{bvid}:cid:{cid}"), + ) +} - #[test] - fn live_playback_prefers_the_main_route() { - let playback_info = || super::PlaybackInfo { - thumbnail: None, - medias: Vec::new(), - default_media_index: None, - subtitles: Vec::new(), - default_subtitle_index: None, - danmakus: Vec::new(), - default_danmaku_index: None, - }; - let playback_infos = [ - ("backup_1".to_string(), playback_info()), - ("main".to_string(), playback_info()), - ] - .into_iter() - .collect(); +fn bilibili_pgc_content_descriptor(epid: u64, cid: u64) -> String { + format!("pgc:epid:{epid}:cid:{cid}") +} - assert_eq!(default_bilibili_live_mode(&playback_infos), "main"); +fn bilibili_content_descriptor(result: &PlaybackResult) -> Option { + let Some(PlaybackMetadata::Bilibili(metadata)) = result.metadata.as_ref() else { + return None; + }; + match metadata.kind { + BilibiliPlaybackKind::Video => Some(bilibili_video_content_descriptor( + metadata.bvid.as_deref(), + metadata.aid.unwrap_or_default(), + metadata.cid?, + )), + BilibiliPlaybackKind::Pgc => Some(bilibili_pgc_content_descriptor( + metadata.epid?, + metadata.cid?, + )), + BilibiliPlaybackKind::Live => None, } +} - #[test] - fn dynamic_live_playback_has_a_live_danmaku_track() { - let room_id = RoomId::new(); - let playlist_id = PlaylistId::new(); - let context = ProviderContext::new("test", ProviderActor::Guest) - .with_room_id(room_id) - .with_playlist_id(playlist_id); - let track = bilibili_live_danmaku_track(&context, 21_292_831) - .expect("dynamic live playback should expose a danmaku track"); +fn bilibili_dash_manifest_swarm_id( + provider_instance_name: Option<&str>, + content_descriptor: &str, +) -> String { + super::provider_p2p_swarm_id( + BilibiliProvider::NAME, + provider_instance_name, + "dash-media-set", + &format!("{content_descriptor}:schema:{BILIBILI_DASH_SWARM_SCHEMA_VERSION}"), + ) +} - assert!(matches!( - track.provider, - crate::models::media::PlaybackDanmakuProvider::Bilibili( - crate::models::media::PlaybackBilibiliDanmaku::DynamicLive { - room_id: track_room_id, - playlist_id: track_playlist_id, - live_room_id: 21_292_831, - } - ) if track_room_id == room_id && track_playlist_id == playlist_id +fn bilibili_durl_media( + name: &str, + segments: impl IntoIterator, u64)>, + p2p_swarm_id: String, +) -> Result { + let segments = segments + .into_iter() + .map(|(url, backup_urls, duration_millis)| BilibiliDurlSegment { + url, + backup_urls, + duration_millis, + }) + .collect::>(); + if segments.is_empty() { + return Err(ProviderError::ApiError( + "Bilibili DURL playback is empty".to_string(), )); } + let expires_at = segments + .iter() + .flat_map(|segment| std::iter::once(&segment.url).chain(&segment.backup_urls)) + .filter_map(|url| bilibili_url_expiration_timestamp(url)) + .min(); + Ok(playback_media( + name.to_string(), + "m3u8".to_string(), + expires_at, + Some(p2p_swarm_id), + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { + version: String::new(), + expires_at: 0, + mode_name: "mp4".to_string(), + segments, + headers: bilibili_headers(), + }), + )) +} - #[test] - fn cached_dynamic_live_playback_attaches_danmaku_to_proxy_route() { - let room_id = RoomId::new(); - let playlist_id = PlaylistId::new(); - let context = ProviderContext::new("test", ProviderActor::Guest) - .with_room_id(room_id) - .with_playlist_id(playlist_id); - let mut result = PlaybackResult { - playback_infos: HashMap::from([( - "main".to_string(), - PlaybackInfo { - thumbnail: None, - medias: vec![super::playback_media( - "Live HLS".to_string(), - "m3u8".to_string(), - Some(456), - None, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::Direct { - url: "https://live.example.com/main.m3u8".to_string(), - headers: HashMap::new(), - }), - )], - default_media_index: Some(0), - subtitles: Vec::new(), - default_subtitle_index: None, - danmakus: Vec::new(), - default_danmaku_index: None, - }, - )]), - default_mode: "main".to_string(), - provider: crate::models::SourceProvider::Bilibili, - provider_instance_name: None, - duration_seconds: None, - playback_kind: Some(crate::models::PlaybackKind::Live), - metadata: Some(PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { - room_id: Some(21_292_831), - is_live: true, - is_currently_live: Some(true), - ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Live) - })), - }; - - mark_bilibili_playback_resources( - &mut result, - "version", - 123, - crate::models::PlaybackProxyMode::Auto, - Some(&context), - ); +fn bilibili_durl_resource_candidates( + media: &PlaybackMedia, + target_url: &str, +) -> Option> { + let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { segments, .. }) = + &media.provider + else { + return None; + }; + let segment = segments.iter().find(|segment| segment.url == target_url)?; + let mut candidates = Vec::with_capacity(1 + segment.backup_urls.len()); + for candidate in std::iter::once(&segment.url).chain(&segment.backup_urls) { + if !candidate.trim().is_empty() && !candidates.contains(candidate) { + candidates.push(candidate.clone()); + } + } + (!candidates.is_empty()).then_some(candidates) +} - let info = &result.playback_infos["proxy_main"]; - assert_eq!(info.default_danmaku_index, Some(0)); - assert!(matches!( - info.danmakus.as_slice(), - [crate::models::media::PlaybackDanmaku { - provider: PlaybackDanmakuProvider::Bilibili( - PlaybackBilibiliDanmaku::DynamicLive { - room_id: track_room_id, - playlist_id: track_playlist_id, - live_room_id: 21_292_831, - } - ), - .. - }] if *track_room_id == room_id && *track_playlist_id == playlist_id +fn build_bilibili_durl_manifest(segments: &[BilibiliDurlSegment]) -> Result { + if segments.is_empty() { + return Err(ProviderError::NotFound); + } + if segments.iter().any(|segment| { + std::iter::once(&segment.url) + .chain(&segment.backup_urls) + .any(|url| url.contains('\r') || url.contains('\n')) + }) { + return Err(ProviderError::ApiError( + "Bilibili DURL segment URL contains a line break".to_string(), )); } + let target_duration = segments + .iter() + .map(|segment| segment.duration_millis.div_ceil(1_000)) + .max() + .unwrap_or(1) + .max(1); + let mut manifest = format!( + "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:{target_duration}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n" + ); + for (index, segment) in segments.iter().enumerate() { + if index > 0 { + manifest.push_str("#EXT-X-DISCONTINUITY\n"); + } + let duration_seconds = segment.duration_millis / 1_000; + let duration_millis = segment.duration_millis % 1_000; + let _ = writeln!(manifest, "#EXTINF:{duration_seconds}.{duration_millis:03},"); + let _ = writeln!(manifest, "{}", segment.url); + } + manifest.push_str("#EXT-X-ENDLIST\n"); + Ok(manifest) +} - #[test] - fn live_playback_uses_the_transport_advertised_by_the_client() -> TestResult { - let hls_profile = super::super::PlaybackClientProfile { - media_capabilities: vec![super::super::PlaybackMediaCapability { - transport: super::super::PlaybackMediaTransport::Hls, - container: None, - video_codec: None, - audio_codec: None, - pipeline: super::super::PlaybackMediaPipeline::Native, - codec_string: None, - }], - ..Default::default() - }; - assert!(super::bilibili_live_uses_hls(Some(&hls_profile))); +fn bilibili_live_headers() -> HashMap { + let mut headers = bilibili_headers(); + headers.insert( + "Referer".to_string(), + "https://live.bilibili.com".to_string(), + ); + headers +} - let native_profile = super::super::PlaybackClientProfile { - supported_live_transports: vec![super::super::PlaybackLiveTransport::Flv], - media_capabilities: vec![super::super::PlaybackMediaCapability { - transport: super::super::PlaybackMediaTransport::Flv, - container: None, - video_codec: None, - audio_codec: None, - pipeline: super::super::PlaybackMediaPipeline::Native, - codec_string: None, - }], - ..Default::default() - }; - assert!(!super::bilibili_live_uses_hls(Some(&native_profile))); +fn bilibili_live_danmaku_track( + ctx: &ProviderContext<'_>, + live_room_id: u64, +) -> Option { + let room_id = ctx.room_id()?; + let provider = match (ctx.media_id(), ctx.playlist_id()) { + (Some(media_id), _) => PlaybackBilibiliDanmaku::Live { + room_id: *room_id, + media_id: *media_id, + }, + (None, Some(playlist_id)) => PlaybackBilibiliDanmaku::DynamicLive { + room_id: *room_id, + playlist_id: *playlist_id, + live_room_id, + }, + (None, None) => return None, + }; + Some(PlaybackDanmaku { + name: LIVE_DANMAKU_TRACK_NAME.to_string(), + format: Some(LIVE_DANMAKU_FORMAT.to_string()), + p2p_swarm_id: None, + provider: PlaybackDanmakuProvider::Bilibili(provider), + }) +} - let web_flv_profile = super::super::PlaybackClientProfile { - profile_version: super::super::CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, - environment: super::super::PlaybackClientEnvironment::Web, - media_capabilities: vec![super::super::PlaybackMediaCapability { - transport: super::super::PlaybackMediaTransport::Flv, - container: None, - video_codec: Some(super::super::PlaybackVideoCodec::H264), - audio_codec: Some(super::super::PlaybackAudioCodec::Aac), - pipeline: super::super::PlaybackMediaPipeline::MediaSource, - codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()), - }], - supported_live_transports: Vec::new(), - ..Default::default() - }; - assert!(!super::bilibili_live_uses_hls(Some(&web_flv_profile))); +fn bilibili_vod_danmaku_track( + provider_instance_name: Option<&str>, + content_descriptor: &str, + cid: u64, +) -> PlaybackDanmaku { + PlaybackDanmaku { + name: "Bilibili danmaku".to_string(), + format: Some("xml".to_string()), + p2p_swarm_id: Some(super::provider_p2p_swarm_id( + BilibiliProvider::NAME, + provider_instance_name, + "danmaku", + &format!("{content_descriptor}:cid:{cid}"), + )), + provider: PlaybackDanmakuProvider::Bilibili(PlaybackBilibiliDanmaku::FileDirect { + url: format!("https://api.bilibili.com/x/v1/dm/list.so?oid={cid}"), + headers: bilibili_headers(), + expire_at: None, + }), + } +} - let config = super::BilibiliSourceConfig::Live(crate::models::BilibiliLiveSourceConfig { - room_id: 42, - shared: false, - proxy_mode: crate::models::PlaybackProxyMode::Auto, - }); - let (hls_key, _) = provider_ok(super::playback_cache_entry( - &config, - "anonymous", - Some(&hls_profile), - ))?; - let (flv_key, _) = provider_ok(super::playback_cache_entry( - &config, - "anonymous", - Some(&native_profile), - ))?; - let (web_flv_key, _) = provider_ok(super::playback_cache_entry( - &config, - "anonymous", - Some(&web_flv_profile), - ))?; - assert!(hls_key.ends_with(":hls")); - assert!(flv_key.ends_with(":flv")); - assert!(web_flv_key.ends_with(":flv")); - assert_ne!(hls_key, flv_key); - Ok(()) +fn bilibili_dash_video_url_request( + cookies: &HashMap, + aid: u64, + bvid: String, + cid: u64, +) -> bilibili_upstream::GetDashVideoUrlReq { + bilibili_upstream::GetDashVideoUrlReq { + aid, + bvid, + cid, + cookies: cookies.clone(), } +} - #[test] - fn durl_segments_are_exposed_as_one_ordered_vod_manifest() -> TestResult { - let media = provider_ok(bilibili_durl_media( - "MP4", - [ - ( - "https://cdn.example/part-1.mp4?deadline=200".to_string(), - vec!["https://backup.example/part-1.mp4?deadline=75".to_string()], - 1_250, - ), - ( - "https://cdn.example/part-2.mp4?deadline=100".to_string(), - Vec::new(), - 2_500, - ), - ], - "sm3_test_durl".to_string(), - ))?; - let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { - segments, - .. - }) = &media.provider - else { - anyhow::bail!("DURL media should use a direct in-memory manifest"); - }; +fn bilibili_video_url_request( + cookies: &HashMap, + aid: u64, + bvid: String, + cid: u64, + quality: u64, +) -> bilibili_upstream::GetVideoUrlReq { + bilibili_upstream::GetVideoUrlReq { + aid, + bvid, + cid, + quality, + cookies: cookies.clone(), + } +} - let manifest = provider_ok(build_bilibili_durl_manifest(segments))?; - assert_eq!(media.expire_at.map(|value| value.timestamp()), Some(75)); - assert_eq!( - bilibili_durl_resource_candidates( - &media, - "https://cdn.example/part-1.mp4?deadline=200" - ), - None - ); - let restored: crate::models::PlaybackMedia = - serde_json::from_value(serde_json::to_value(&media)?)?; - assert_eq!(media.p2p_swarm_id.as_deref(), Some("sm3_test_durl")); - assert_eq!(restored.p2p_swarm_id, media.p2p_swarm_id); - assert!(matches!( - restored.provider, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) - )); - assert_eq!(manifest.matches("#EXTINF:").count(), 2); - assert_eq!(manifest.matches("#EXT-X-DISCONTINUITY").count(), 1); - assert!(manifest.contains("#EXT-X-TARGETDURATION:3")); - assert!(manifest.contains("#EXTINF:1.250,\nhttps://cdn.example/part-1.mp4")); - assert!(manifest.contains("#EXTINF:2.500,\nhttps://cdn.example/part-2.mp4")); - assert!(manifest.ends_with("#EXT-X-ENDLIST\n")); - Ok(()) +fn bilibili_subtitles_request( + cookies: &HashMap, + aid: u64, + bvid: String, + cid: u64, +) -> bilibili_upstream::GetSubtitlesReq { + bilibili_upstream::GetSubtitlesReq { + aid, + bvid, + cid, + cookies: cookies.clone(), } +} - #[test] - fn durl_media_expiry_is_derived_only_from_upstream_urls() -> TestResult { - let undated = provider_ok(bilibili_durl_media( - "MP4", - [( - "https://cdn.example/video.mp4".to_string(), - Vec::new(), - 1_000, - )], - "sm3_test_durl_undated".to_string(), - ))?; - assert_eq!(undated.expire_at, None); +fn bilibili_dash_pgc_url_request( + cookies: &HashMap, + epid: u64, + cid: u64, +) -> bilibili_upstream::GetDashPgcurlReq { + bilibili_upstream::GetDashPgcurlReq { + epid, + cid, + cookies: cookies.clone(), + } +} - let upstream_expires_at = 4_102_444_800; - let dated = provider_ok(bilibili_durl_media( - "MP4", - [( - format!("https://cdn.example/video.mp4?deadline={upstream_expires_at}"), - Vec::new(), - 1_000, - )], - "sm3_test_durl_dated".to_string(), - ))?; - assert_eq!( - dated.expire_at.map(|value| value.timestamp()), - Some(upstream_expires_at) - ); - Ok(()) +fn bilibili_pgc_url_request( + cookies: &HashMap, + epid: u64, + cid: u64, + quality: u64, +) -> bilibili_upstream::GetPgcurlReq { + bilibili_upstream::GetPgcurlReq { + epid, + cid, + quality, + cookies: cookies.clone(), } +} - #[test] - fn dash_resources_keep_server_side_backup_candidates() -> TestResult { - let primary = "https://primary.example/video.m4s?deadline=200"; - let backup = "https://backup.example/video.m4s?deadline=200"; - let dash = BilibiliDashManifest { - video_streams: vec![BilibiliDashVideoStream { - base_url: primary.to_string(), - backup_urls: vec![backup.to_string(), backup.to_string()], - ..Default::default() - }], - ..Default::default() - }; +fn bilibili_live_streams_request( + cookies: HashMap, + room_id: u64, + hls: bool, +) -> bilibili_upstream::GetLiveStreamsReq { + bilibili_upstream::GetLiveStreamsReq { + cid: room_id, + hls, + cookies, + } +} - assert_eq!( - provider_ok(bilibili_dash_resource_candidates(&dash, primary, "", None))?, - Some(vec![primary.to_string(), backup.to_string()]) - ); - assert_eq!( - provider_ok(bilibili_dash_resource_candidates(&dash, backup, "", None))?, - Some(vec![backup.to_string(), primary.to_string()]) - ); - assert_eq!( - provider_ok(bilibili_dash_resource_candidates( - &dash, - "https://unknown.example/video.m4s", - "", - None - ))?, - None - ); - Ok(()) +fn bilibili_live_uses_hls(profile: Option<&super::PlaybackClientProfile>) -> bool { + !profile.is_some_and(|profile| { + profile.supports_transport(super::PlaybackMediaTransport::Flv) + && !profile.supports_transport(super::PlaybackMediaTransport::Hls) + }) +} + +fn bilibili_live_transport_cache_token( + profile: Option<&super::PlaybackClientProfile>, +) -> &'static str { + if bilibili_live_uses_hls(profile) { + "hls" + } else { + "flv" } +} - #[test] - fn durl_manifest_keeps_a_proxy_sibling_for_proxy_only_mode() -> TestResult { - let media = provider_ok(bilibili_durl_media( - "MP4", - [( - "https://cdn.example/video.mp4?deadline=200".to_string(), - Vec::new(), - 1_000, - )], - "sm3_test_durl_proxy".to_string(), - ))?; - let mut result = PlaybackResult { - playback_infos: HashMap::from([( - "durl".to_string(), - PlaybackInfo { - thumbnail: None, - medias: vec![media], - default_media_index: Some(0), - subtitles: vec![bilibili_subtitle_track( - None, - "video:BV1test:cid:1", - "Chinese", - "https://subtitle.bilibili.com/subtitle.json?deadline=200".to_string(), - )], - default_subtitle_index: Some(0), - danmakus: vec![bilibili_vod_danmaku_track(None, "video:BV1test:cid:1", 1)], - default_danmaku_index: Some(0), - }, - )]), - default_mode: "durl".to_string(), - provider: crate::models::SourceProvider::Bilibili, - provider_instance_name: None, - duration_seconds: None, - playback_kind: Some(crate::models::PlaybackKind::Regular), - metadata: None, +impl BilibiliProvider { + /// Resolve playback result from Bilibili API (no caching). + /// Cookies are resolved from the credential store, not from source_config. + async fn resolve_from_api_with_cookies( + &self, + ctx: &ProviderContext<'_>, + config: &BilibiliSourceConfig, + cookies: &HashMap, + provider_instance_name: Option<&str>, + request_context: Option<&super::ExecutionControl>, + ) -> Result { + let sanitized_cookies = cookies.clone(); + let client = self + .get_client_with_context(provider_instance_name, request_context) + .await?; + + let mode_info = |medias: Vec, + subtitles: Vec, + danmakus: Vec| { + let default_danmaku_index = (!danmakus.is_empty()).then_some(0); + PlaybackInfo { + thumbnail: None, + medias, + default_media_index: None, + subtitles, + default_subtitle_index: None, + danmakus, + default_danmaku_index, + } }; - let direct_only_base = result.clone(); - mark_bilibili_playback_resources( - &mut result, - "version", - 123, - crate::models::PlaybackProxyMode::Auto, - None, - ); - assert_eq!(result.default_mode, "proxy_durl"); - assert!(result.playback_infos.contains_key("proxy_durl")); - assert!(!result.playback_infos.contains_key("durl")); - assert!(matches!( - result.playback_infos["proxy_durl"].medias[0].provider, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { .. }) - )); - assert!(matches!( - result.playback_infos["proxy_durl"].subtitles[0].provider, - PlaybackSubtitleProvider::Bilibili(PlaybackBilibiliSubtitle::Proxy { .. }) - )); - assert!(matches!( - result.playback_infos["proxy_durl"].danmakus[0].provider, - PlaybackDanmakuProvider::Bilibili(PlaybackBilibiliDanmaku::FileProxy { .. }) - )); + match config { + BilibiliSourceConfig::Video(config) => { + let (bvid, aid) = + resolve_bilibili_video_identifier(config.bvid.as_deref(), config.aid)?; + let request_bvid = bvid.clone().unwrap_or_default(); + let cid = config.cid; + let content_descriptor = + bilibili_video_content_descriptor(bvid.as_deref(), aid, cid); - let mut direct_only = direct_only_base.clone(); - mark_bilibili_playback_resources( - &mut direct_only, - "version", - 123, - crate::models::PlaybackProxyMode::DirectOnly, - None, - ); - assert_eq!(direct_only.default_mode, "durl"); - assert!(matches!( - direct_only.playback_infos["durl"].medias[0].provider, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) - )); - assert!(!direct_only.playback_infos.contains_key("proxy_durl")); - assert!(matches!( - direct_only.playback_infos["durl"].medias[0].provider, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) - )); + let request = bilibili_dash_video_url_request( + &sanitized_cookies, + aid, + request_bvid.clone(), + cid, + ); + let dash_resp = client.get_dash_video_url(request).await; + let dash_resp = match dash_resp { + Ok(dash_resp) if dash_resp.dash.is_some() => Some(dash_resp), + Ok(_) => None, + Err(error) if is_bilibili_video_dash_unavailable(&error) => None, + Err(error) => return Err(error.into()), + }; - let mut proxy_only = direct_only_base; - mark_bilibili_playback_resources( - &mut proxy_only, - "version", - 123, - crate::models::PlaybackProxyMode::Only, - None, - ); - assert_eq!(proxy_only.default_mode, "proxy_durl"); - assert!(!proxy_only.playback_infos.contains_key("durl")); - assert_eq!(proxy_only.playback_infos["proxy_durl"].medias.len(), 1); - assert!(matches!( - proxy_only.playback_infos["proxy_durl"].subtitles[0].provider, - PlaybackSubtitleProvider::Bilibili(PlaybackBilibiliSubtitle::Proxy { .. }) - )); - assert!(matches!( - proxy_only.playback_infos["proxy_durl"].danmakus[0].provider, - PlaybackDanmakuProvider::Bilibili(PlaybackBilibiliDanmaku::FileProxy { .. }) - )); - Ok(()) - } + let mut metadata = PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { + bvid, + aid: Some(aid), + cid: Some(cid), + ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video) + }); + let mut subtitles = Vec::new(); - #[test] - fn web_durl_with_required_headers_only_exposes_the_proxy_route() -> TestResult { - let profile = web_hls_profile(); - let context = ProviderContext::new("test", ProviderActor::System) - .with_playback_client_profile(Some(profile)); - let mut result = durl_test_result()?; + let subtitle_request = + bilibili_subtitles_request(&sanitized_cookies, aid, request_bvid.clone(), cid); + match client.get_subtitles(subtitle_request).await { + Ok(subtitle_resp) => { + subtitles = subtitle_resp + .subtitles + .into_iter() + .map(|(name, url)| { + bilibili_subtitle_track( + provider_instance_name, + &content_descriptor, + &name, + url, + ) + }) + .collect(); + } + Err(e) => { + tracing::warn!( + bvid = %request_bvid, aid = %aid, cid = %cid, error = %e, + "Failed to fetch Bilibili subtitles for video, continuing without subtitles" + ); + } + } - mark_bilibili_playback_resources( - &mut result, - "version", - 123, - crate::models::PlaybackProxyMode::DirectPrefer, - Some(&context), - ); + let duration_seconds = dash_resp + .as_ref() + .and_then(|resp| resp.dash.as_ref()) + .map(|dash| dash.duration); + if let Some(dash) = dash_resp.as_ref().and_then(|resp| resp.dash.as_ref()) { + if let Some(metadata) = metadata.as_bilibili_mut() { + metadata.min_buffer_time = Some(dash.min_buffer_time); + } + } - assert_eq!(result.default_mode, "proxy_durl"); - assert!(!result.playback_infos.contains_key("durl")); - assert!(result.playback_infos.contains_key("proxy_durl")); - Ok(()) - } + let mut playback_infos = HashMap::new(); + let danmakus = vec![bilibili_vod_danmaku_track( + provider_instance_name, + &content_descriptor, + cid, + )]; - #[test] - fn web_direct_only_durl_returns_structured_client_incompatibility() -> TestResult { - let profile = web_hls_profile(); - let context = ProviderContext::new("test", ProviderActor::System) - .with_playback_client_profile(Some(profile.clone())); - let mut result = durl_test_result()?; - mark_bilibili_playback_resources( - &mut result, - "version", - 123, - crate::models::PlaybackProxyMode::DirectOnly, - Some(&context), - ); + let default_mode = if let Some(dash_resp) = dash_resp { + let dash = dash_resp.dash.as_ref().ok_or_else(|| { + ProviderError::ApiError( + "Bilibili video playback response did not include DASH streams" + .to_string(), + ) + })?; + let primary_dash = dash_manifest_from_upstream(dash); + let hevc_dash = dash_resp + .hevc_dash + .as_ref() + .filter(|dash| !dash.video_streams.is_empty()) + .map(dash_manifest_from_upstream); + let (dash_infos, default_mode) = bilibili_dash_playback_infos( + &mut metadata, + &content_descriptor, + &primary_dash, + hevc_dash.as_ref(), + BilibiliDashPlaybackOptions { + provider_instance_name, + subtitles: &subtitles, + danmakus: &danmakus, + client_profile: ctx.playback_client_profile(), + }, + )?; + playback_infos.extend(dash_infos); + default_mode + } else { + let request = + bilibili_video_url_request(&sanitized_cookies, aid, request_bvid, cid, 80); + let video_resp = client.get_video_url(request).await?; + if let Some(metadata) = metadata.as_bilibili_mut() { + metadata.fallback_format = Some("durl".to_string()); + metadata.quality = Some(video_resp.current_quality); + } + let durl_swarm_id = super::provider_p2p_swarm_id( + Self::NAME, + provider_instance_name, + "media", + &format!( + "{content_descriptor}:durl:quality:{}", + video_resp.current_quality + ), + ); + playback_infos.insert( + "mp4".to_string(), + mode_info( + vec![bilibili_durl_media( + "MP4", + video_resp.segments.into_iter().map(|segment| { + (segment.url, segment.backup_urls, segment.duration_millis) + }), + durl_swarm_id, + )?], + subtitles, + danmakus, + ), + ); + "mp4".to_string() + }; - let error = super::super::require_compatible_playback_route( - result, - crate::models::PlaybackProxyMode::DirectOnly, - Some(&profile), - ) - .expect_err("DirectOnly cannot satisfy browser header restrictions"); - assert!(matches!( - error, - super::ProviderError::ClientIncompatible { - required_capability: Some(ref capability), - .. - } if capability == "browser_direct_media_access_or_provider_proxy" - )); - Ok(()) - } + Ok(PlaybackResult { + playback_infos, + default_mode, + provider: crate::models::SourceProvider::Bilibili, + provider_instance_name: provider_instance_name.map(str::to_string), + duration_seconds, + playback_kind: Some(crate::models::PlaybackKind::Regular), + metadata: Some(metadata), + }) + } - #[tokio::test] - async fn bilibili_subtitle_uses_full_response_cache_transport() -> TestResult { - let store: Arc = Arc::new(InMemoryProviderStore::new(8)); - let subtitle_url = "https://subtitle.bilibili.com/track.json"; - let playback = PlaybackResult { - playback_infos: HashMap::from([( - "proxy_dash".to_string(), - PlaybackInfo { - thumbnail: None, - medias: Vec::new(), - default_media_index: None, - subtitles: vec![bilibili_subtitle_track( - None, - "video:BV1test:cid:1", - "Chinese", - subtitle_url.to_string(), - )], - default_subtitle_index: Some(0), - danmakus: Vec::new(), - default_danmaku_index: None, - }, - )]), - default_mode: "proxy_dash".to_string(), - provider: crate::models::SourceProvider::Bilibili, - provider_instance_name: None, - duration_seconds: None, - playback_kind: Some(crate::models::PlaybackKind::Regular), - metadata: None, - }; - store - .set( - "v:subtitle-test", - &VersionedPlayback { - version: "subtitle-test".to_string(), - result: playback, - expires_at: crate::SystemClock.now().timestamp() + 60, - playback_context: None, - }, - std::time::Duration::from_secs(60), - ) - .await?; + BilibiliSourceConfig::Pgc(config) => { + let epid = config.epid; + let cid = config.cid; + let content_descriptor = bilibili_pgc_content_descriptor(epid, cid); - let provider = BilibiliProvider::new_local_only()?; - let action = provider - .get_subtitle(Some(&store), "subtitle-test", "proxy_dash", 0, None) - .await?; + let request = bilibili_dash_pgc_url_request(&sanitized_cookies, epid, cid); + let first_dash_resp = client.get_dash_pgcurl(request.clone()).await; + let dash_resp = if matches!( + &first_dash_resp, + Ok(response) if response.dash.is_none() + ) || matches!( + &first_dash_resp, + Err(error) if is_bilibili_pgc_dash_unavailable(error) + ) { + tracing::warn!( + epid, + cid, + "Bilibili PGC DASH response was empty; retrying once before DURL fallback" + ); + tokio::time::sleep(BILIBILI_DASH_RETRY_DELAY).await; + client.get_dash_pgcurl(request).await + } else { + first_dash_resp + }; + let dash_resp = match dash_resp { + Ok(dash_resp) if dash_resp.dash.is_some() => Some(dash_resp), + Ok(_) => None, + Err(error) if is_bilibili_pgc_dash_unavailable(&error) => None, + Err(error) => return Err(error.into()), + }; - let PlaybackTransportAction::FetchAndForward { - url, - headers, - range_header, - proxy_strategy, - } = action - else { - anyhow::bail!("Bilibili subtitles must use the full response cache transport"); - }; - assert_eq!(url, subtitle_url); - assert_eq!(range_header, None); - assert_eq!( - proxy_strategy, - crate::provider::PlaybackResourceProxyStrategy::FullResponseCache - ); - assert_eq!( - headers.get("Referer").map(String::as_str), - Some("https://www.bilibili.com") - ); - assert!(headers.contains_key("User-Agent")); - assert!( - !headers - .keys() - .any(|name| name.eq_ignore_ascii_case("range")), - "Bilibili subtitle provider headers must leave Range ownership to the transport layer" - ); - Ok(()) - } + let mut metadata = PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { + epid: Some(epid), + cid: Some(cid), + ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Pgc) + }); + let mut subtitles = Vec::new(); - #[test] - fn dash_video_codec_family_uses_the_exact_codec_string() { - let regular = bilibili_upstream::DashInfo { - video_streams: vec![bilibili_upstream::VideoStream::default()], - ..Default::default() - }; - let hevc = bilibili_upstream::DashInfo { - video_streams: vec![bilibili_upstream::VideoStream { - codecs: "hev1.1.6.L120.90".to_string(), - ..Default::default() - }], - ..Default::default() - }; + let subtitle_request = + bilibili_subtitles_request(&sanitized_cookies, 0, String::new(), cid); + match client.get_subtitles(subtitle_request).await { + Ok(subtitle_resp) => { + subtitles = subtitle_resp + .subtitles + .into_iter() + .map(|(name, url)| { + bilibili_subtitle_track( + provider_instance_name, + &content_descriptor, + &name, + url, + ) + }) + .collect(); + } + Err(e) => { + tracing::warn!( + epid = %epid, cid = %cid, error = %e, + "Failed to fetch Bilibili subtitles for PGC content, continuing without subtitles" + ); + } + } - assert_eq!( - bilibili_dash_video_codec(®ular.video_streams[0].codecs), - PlaybackVideoCodec::H264 - ); - assert_eq!( - bilibili_dash_video_codec(&hevc.video_streams[0].codecs), - PlaybackVideoCodec::Hevc - ); - assert_eq!( - bilibili_dash_video_codec("vp09.00.21.08"), - PlaybackVideoCodec::Vp9 - ); - } + let duration_seconds = dash_resp + .as_ref() + .and_then(|resp| resp.dash.as_ref()) + .map(|dash| dash.duration); + if let Some(dash) = dash_resp.as_ref().and_then(|resp| resp.dash.as_ref()) { + if let Some(metadata) = metadata.as_bilibili_mut() { + metadata.min_buffer_time = Some(dash.min_buffer_time); + } + } - #[test] - fn dash_codecs_are_exposed_in_one_playback_mode() -> TestResult { - let video = |id: u64, codecs: &str, url: &str| BilibiliDashVideoStream { - id, - codecs: codecs.to_string(), - base_url: url.to_string(), - ..Default::default() - }; - let dash = BilibiliDashManifest { - video_streams: vec![ - video( - 1, - "avc1.640033", - "https://cdn.example/h264.m4s?deadline=100", - ), - video( - 2, - "av01.0.08M.08", - "https://cdn.example/av1.m4s?deadline=100", - ), - video( - 3, - "hev1.1.6.L120.90", - "https://cdn.example/hevc.m4s?deadline=100", - ), - ], - audio_streams: vec![ - BilibiliDashAudioStream { - id: 30_280, - codecs: "mp4a.40.2".to_string(), - base_url: "https://cdn.example/aac.m4s?deadline=100".to_string(), - ..Default::default() - }, - BilibiliDashAudioStream { - id: 30_250, - codecs: "ec-3".to_string(), - base_url: "https://cdn.example/eac3.m4s?deadline=100".to_string(), - ..Default::default() - }, - BilibiliDashAudioStream { - id: 30_251, - codecs: "fLaC".to_string(), - base_url: "https://cdn.example/flac.m4s?deadline=100".to_string(), - ..Default::default() - }, - ], - ..Default::default() - }; - let mut metadata = - PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); - let (infos, default_mode) = bilibili_dash_playback_infos( - &mut metadata, - "video:bvid:BV1test:cid:42", - &dash, - None, - BilibiliDashPlaybackOptions { - provider_instance_name: None, - subtitles: &[], - danmakus: &[], - client_profile: None, - }, - )?; + let mut playback_infos = HashMap::new(); + let danmakus = vec![bilibili_vod_danmaku_track( + provider_instance_name, + &content_descriptor, + cid, + )]; - assert_eq!(default_mode, "dash"); - assert_eq!(infos.len(), 1); - let medias = &infos["dash"].medias; - assert_eq!(medias.len(), 1); - assert_eq!(medias[0].name, "DASH"); - assert_eq!(medias[0].format, "mpd"); - assert!(matches!( - &medias[0].provider, - PlaybackMediaProvider::Bilibili( - PlaybackBilibiliMedia::DirectDashManifest { mode_name, .. } - ) if mode_name == "dash" - )); - let PlaybackMetadata::Bilibili(metadata) = metadata else { - anyhow::bail!("expected Bilibili metadata"); - }; - let Some(manifest) = metadata.dash_manifest.as_ref() else { - anyhow::bail!("expected unified DASH manifest"); - }; - assert_eq!(manifest.video_streams.len(), 3); - assert_eq!(manifest.audio_streams.len(), 3); - Ok(()) - } + let default_mode = if let Some(dash_resp) = dash_resp { + let dash = dash_resp.dash.as_ref().ok_or_else(|| { + ProviderError::ApiError( + "Bilibili PGC playback response did not include DASH streams" + .to_string(), + ) + })?; + let primary_dash = dash_manifest_from_upstream(dash); + let hevc_dash = dash_resp + .hevc_dash + .as_ref() + .filter(|dash| !dash.video_streams.is_empty()) + .map(dash_manifest_from_upstream); + let (dash_infos, default_mode) = bilibili_dash_playback_infos( + &mut metadata, + &content_descriptor, + &primary_dash, + hevc_dash.as_ref(), + BilibiliDashPlaybackOptions { + provider_instance_name, + subtitles: &subtitles, + danmakus: &danmakus, + client_profile: ctx.playback_client_profile(), + }, + )?; + playback_infos.extend(dash_infos); + default_mode + } else { + let request = bilibili_pgc_url_request(&sanitized_cookies, epid, cid, 80); + let pgc_resp = client.get_pgcurl(request).await?; + if let Some(metadata) = metadata.as_bilibili_mut() { + metadata.fallback_format = Some("durl".to_string()); + metadata.quality = Some(pgc_resp.current_quality); + } + let durl_swarm_id = super::provider_p2p_swarm_id( + Self::NAME, + provider_instance_name, + "media", + &format!( + "{content_descriptor}:durl:quality:{}", + pgc_resp.current_quality + ), + ); + playback_infos.insert( + "mp4".to_string(), + mode_info( + vec![bilibili_durl_media( + "MP4", + pgc_resp.segments.into_iter().map(|segment| { + (segment.url, segment.backup_urls, segment.duration_millis) + }), + durl_swarm_id, + )?], + subtitles, + danmakus, + ), + ); + "mp4".to_string() + }; - #[test] - fn unified_dash_merges_unique_video_and_audio_from_additional_response() -> TestResult { - let primary = BilibiliDashManifest { - video_streams: vec![BilibiliDashVideoStream { - id: 80, - codecid: 7, - codecs: "avc1.640032".to_string(), - base_url: "https://cdn.example/h264.m4s".to_string(), - ..Default::default() - }], - audio_streams: vec![BilibiliDashAudioStream { - id: 30_280, - codecs: "mp4a.40.2".to_string(), - base_url: "https://cdn.example/aac.m4s".to_string(), - ..Default::default() - }], - ..Default::default() - }; - let additional = BilibiliDashManifest { - video_streams: vec![BilibiliDashVideoStream { - id: 80, - codecid: 12, - codecs: "hev1.1.6.L120.90".to_string(), - base_url: "https://cdn.example/hevc.m4s".to_string(), - ..Default::default() - }], - audio_streams: vec![ - primary.audio_streams[0].clone(), - BilibiliDashAudioStream { - id: 30_251, - codecs: "fLaC".to_string(), - base_url: "https://cdn.example/flac.m4s".to_string(), - ..Default::default() - }, - ], - ..Default::default() - }; - let mut metadata = - PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + Ok(PlaybackResult { + playback_infos, + default_mode, + provider: crate::models::SourceProvider::Bilibili, + provider_instance_name: provider_instance_name.map(str::to_string), + duration_seconds, + playback_kind: Some(crate::models::PlaybackKind::Regular), + metadata: Some(metadata), + }) + } - bilibili_dash_playback_infos( - &mut metadata, - "video:bvid:BV1test:cid:42", - &primary, - Some(&additional), - BilibiliDashPlaybackOptions { - provider_instance_name: None, - subtitles: &[], - danmakus: &[], - client_profile: None, - }, - )?; + BilibiliSourceConfig::Live(config) => { + let room_id = config.room_id; - let PlaybackMetadata::Bilibili(metadata) = metadata else { - anyhow::bail!("expected Bilibili metadata"); - }; - let Some(manifest) = metadata.dash_manifest else { - anyhow::bail!("expected unified DASH manifest"); - }; - assert_eq!(manifest.video_streams.len(), 2); - assert_eq!(manifest.audio_streams.len(), 2); - assert_eq!(manifest.audio_streams[1].codecs, "fLaC"); - Ok(()) + let request = bilibili_live_streams_request( + sanitized_cookies.clone(), + room_id, + bilibili_live_uses_hls(ctx.playback_client_profile()), + ); + let page_request = bilibili_parse_live_page_request(BilibiliParseLivePageRequest { + cookies: sanitized_cookies, + room_id, + }); + let (live_resp, live_page) = tokio::join!( + client.get_live_streams(request), + client.parse_live_page(page_request), + ); + let live_resp = live_resp?; + let live_started_at = match live_page { + Ok(page) => page.live_started_at, + Err(error) => { + tracing::warn!( + room_id, + error = %error, + "Failed to fetch Bilibili live start time" + ); + None + } + }; + + let metadata = PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { + room_id: Some(room_id), + live_started_at, + is_live: true, + is_currently_live: Some(true), + ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Live) + }); + // The versioned result is keyed by upstream content. Room and + // playlist identifiers belong to the live danmaku route, so + // they are attached after cache lookup for each request. + let playback_infos = bilibili_live_playback_infos(live_resp.live_streams, &[])?; + + let default_mode = default_bilibili_live_mode(&playback_infos); + + Ok(PlaybackResult { + playback_infos, + default_mode, + provider: crate::models::SourceProvider::Bilibili, + provider_instance_name: provider_instance_name.map(str::to_string), + duration_seconds: None, + playback_kind: Some(crate::models::PlaybackKind::Live), + metadata: Some(metadata), + }) + } + } } +} - #[test] - fn dash_manifest_groups_each_codec_in_a_stable_adaptation_set() -> TestResult { - let video = |id: u64, codecs: &str| BilibiliDashVideoStream { - id, - codecid: u32::try_from(id).expect("test stream id fits u32"), - codecs: codecs.to_string(), - base_url: format!("https://cdn.example/video-{id}.m4s"), - backup_urls: vec![format!("https://backup.example/video-{id}.m4s")], - ..Default::default() - }; - let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { - id, - codecs: codecs.to_string(), - base_url: format!("https://cdn.example/audio-{id}.m4s"), - ..Default::default() - }; - let manifest = BilibiliDashManifest { - video_streams: vec![ - video(1, "avc1.640033"), - video(2, "hev1.1.6.L120.90"), - video(3, "av01.0.08M.08"), - video(4, "vp09.00.21.08"), - ], - audio_streams: vec![ - audio(30_280, "mp4a.40.2"), - audio(30_250, "ec-3"), - audio(30_251, "fLaC"), - ], - duration: 10.0, - min_buffer_time: 1.5, - }; +fn bilibili_live_playback_infos( + mut live_streams: Vec, + danmakus: &[PlaybackDanmaku], +) -> Result, ProviderError> { + live_streams.sort_by(|left, right| { + right + .quality + .cmp(&left.quality) + .then_with(|| { + bilibili_live_format_rank(&left.format) + .cmp(&bilibili_live_format_rank(&right.format)) + }) + .then_with(|| { + bilibili_live_codec_rank(&left.codec).cmp(&bilibili_live_codec_rank(&right.codec)) + }) + }); + let mut route_hosts = Vec::new(); + for stream in &live_streams { + for url in &stream.urls { + if !route_hosts.contains(&url.host) { + route_hosts.push(url.host.clone()); + } + } + } - let mpd = build_bilibili_mpd_manifest(&manifest, |_index, url| url.to_string())?; + let playback_infos = route_hosts + .iter() + .enumerate() + .filter_map(|(route_index, route_host)| { + let medias = live_streams + .iter() + .flat_map(|stream| { + stream + .urls + .iter() + .filter(|&candidate| candidate.host == *route_host) + .map(|candidate| { + let mut media = playback_media( + stream.quality_name.clone(), + bilibili_live_media_format(stream).to_string(), + candidate.expires_at, + None, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::Direct { + url: candidate.url.clone(), + headers: bilibili_live_headers(), + }), + ); + media.metadata = Some(crate::models::PlaybackMediaMetadata { + resolution: None, + bitrate: None, + codec: (!stream.codec.is_empty()).then(|| stream.codec.clone()), + fps: None, + }); + media + }) + }) + .collect::>(); + if medias.is_empty() { + return None; + } + let mode_name = if route_index == 0 { + "main".to_string() + } else { + format!("backup_{route_index}") + }; + Some(( + mode_name, + PlaybackInfo { + thumbnail: None, + medias, + default_media_index: Some(0), + subtitles: Vec::new(), + default_subtitle_index: None, + danmakus: danmakus.to_vec(), + default_danmaku_index: (!danmakus.is_empty()).then_some(0), + }, + )) + }) + .collect::>(); - for (id, label) in [ - (100, "H.264"), - (110, "HEVC"), - (120, "AV1"), - (130, "VP9"), - (200, "AAC"), - (210, "E-AC-3"), - (230, "FLAC"), - ] { - assert!(mpd.contains(&format!("{label}"))); - } - assert_eq!(mpd.matches("")); - assert!(mpd.contains("id=\"video-110-2-2\"")); - assert!(mpd.contains("id=\"audio-230-30251\"")); - Ok(()) + if playback_infos.is_empty() { + return Err(ProviderError::NotFound); } - #[test] - fn dash_manifest_keeps_only_codecs_advertised_by_the_client() { - let video = |id: u64, codecs: &str| BilibiliDashVideoStream { - id, - codecs: codecs.to_string(), - base_url: format!("https://cdn.example/video-{id}.m4s"), - ..Default::default() - }; - let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { - id, - codecs: codecs.to_string(), - base_url: format!("https://cdn.example/audio-{id}.m4s"), - ..Default::default() - }; - let dash = BilibiliDashManifest { - video_streams: vec![ - video(32, "avc1.64001F"), - video(80, "avc1.640032"), - video(120, "avc1.640033"), - ], - audio_streams: vec![ - audio(30_280, "mp4a.40.2"), - audio(30_250, "ec-3"), - audio(30_251, "fLaC"), - ], - ..Default::default() - }; - let profile = PlaybackClientProfile { - profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, - environment: PlaybackClientEnvironment::Web, - media_capabilities: vec![PlaybackMediaCapability { - transport: PlaybackMediaTransport::Dash, - container: Some(crate::provider::PlaybackContainer::Mp4), - video_codec: Some(PlaybackVideoCodec::H264), - audio_codec: Some(PlaybackAudioCodec::Aac), - pipeline: PlaybackMediaPipeline::MediaSource, - codec_string: Some("avc1.64001F,mp4a.40.2".to_string()), - }], - ..PlaybackClientProfile::default() - }; - - let filtered = filter_bilibili_dash_manifest(&dash, Some(&profile)); + Ok(playback_infos) +} - assert_eq!(filtered.video_streams.len(), 1); - assert_eq!(filtered.video_streams[0].codecs, "avc1.64001F"); - assert_eq!(filtered.audio_streams.len(), 1); - assert_eq!(filtered.audio_streams[0].codecs, "mp4a.40.2"); +fn bilibili_live_media_format(stream: &bilibili_upstream::LiveStream) -> &'static str { + if stream.protocol == "http_stream" { + "flv" + } else { + "m3u8" } +} - #[test] - fn dash_playback_metadata_contains_only_client_compatible_codecs() -> TestResult { - let video = |id: u64, codecs: &str| BilibiliDashVideoStream { - id, - codecs: codecs.to_string(), - base_url: format!("https://cdn.example/video-{id}.m4s?deadline=100"), - ..Default::default() - }; - let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { - id, - codecs: codecs.to_string(), - base_url: format!("https://cdn.example/audio-{id}.m4s?deadline=100"), - ..Default::default() - }; - let dash = BilibiliDashManifest { - video_streams: vec![ - video(32, "avc1.64001F"), - video(80, "avc1.640032"), - video(120, "av01.0.08M.08"), - ], - audio_streams: vec![audio(30_280, "mp4a.40.2"), audio(30_250, "ec-3")], - ..Default::default() - }; - let profile = PlaybackClientProfile { - profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, - environment: PlaybackClientEnvironment::Web, - media_capabilities: vec![PlaybackMediaCapability { - transport: PlaybackMediaTransport::Dash, - container: Some(crate::provider::PlaybackContainer::Mp4), - video_codec: Some(PlaybackVideoCodec::H264), - audio_codec: Some(PlaybackAudioCodec::Aac), - pipeline: PlaybackMediaPipeline::MediaSource, - codec_string: Some("avc1.64001F,mp4a.40.2".to_string()), - }], - ..PlaybackClientProfile::default() - }; - let mut metadata = - PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); - - let (infos, default_mode) = bilibili_dash_playback_infos( - &mut metadata, - "video:bvid:BV1test:cid:42", - &dash, - None, - BilibiliDashPlaybackOptions { - provider_instance_name: None, - subtitles: &[], - danmakus: &[], - client_profile: Some(&profile), - }, - )?; +const fn bilibili_live_format_rank(format: &str) -> u8 { + match format.as_bytes() { + b"ts" => 0, + b"fmp4" => 1, + _ => 2, + } +} - assert_eq!(default_mode, "dash"); - assert_eq!(infos.len(), 1); - let PlaybackMetadata::Bilibili(metadata) = metadata else { - anyhow::bail!("expected Bilibili metadata"); - }; - let Some(manifest) = metadata.dash_manifest else { - anyhow::bail!("expected unified DASH manifest"); - }; - assert_eq!( - manifest - .video_streams - .iter() - .map(|stream| stream.codecs.as_str()) - .collect::>(), - vec!["avc1.64001F"] - ); - assert_eq!( - manifest - .audio_streams - .iter() - .map(|stream| stream.codecs.as_str()) - .collect::>(), - vec!["mp4a.40.2"] - ); - Ok(()) +const fn bilibili_live_codec_rank(codec: &str) -> u8 { + match codec.as_bytes() { + b"avc" => 0, + b"hevc" => 1, + _ => 2, } +} - #[test] - fn dash_playback_reports_incompatible_video_and_audio_separately() { - let dash = BilibiliDashManifest { - video_streams: vec![BilibiliDashVideoStream { - id: 32, - codecs: "avc1.64001F".to_string(), - base_url: "https://cdn.example/video.m4s?deadline=100".to_string(), - ..Default::default() - }], - audio_streams: vec![BilibiliDashAudioStream { - id: 30_280, - codecs: "mp4a.40.2".to_string(), - base_url: "https://cdn.example/audio.m4s?deadline=100".to_string(), - ..Default::default() - }], - ..Default::default() - }; - let profile = |video_codec, audio_codec, codec_string: &str| PlaybackClientProfile { - profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, - environment: PlaybackClientEnvironment::Web, - media_capabilities: vec![PlaybackMediaCapability { - transport: PlaybackMediaTransport::Dash, - container: Some(crate::provider::PlaybackContainer::Mp4), - video_codec: Some(video_codec), - audio_codec: Some(audio_codec), - pipeline: PlaybackMediaPipeline::MediaSource, - codec_string: Some(codec_string.to_string()), - }], - ..PlaybackClientProfile::default() - }; +fn default_bilibili_live_mode(playback_infos: &HashMap) -> String { + playback_infos + .contains_key("main") + .then(|| "main".to_string()) + .or_else(|| playback_infos.keys().min().cloned()) + .unwrap_or_else(|| "main".to_string()) +} - let unsupported_video = profile( - PlaybackVideoCodec::Av1, - PlaybackAudioCodec::Aac, - "av01.0.08M.08,mp4a.40.2", - ); - let mut metadata = - PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); - let video_error = bilibili_dash_playback_infos( - &mut metadata, - "video:bvid:BV1test:cid:42", - &dash, - None, - BilibiliDashPlaybackOptions { - provider_instance_name: None, - subtitles: &[], - danmakus: &[], - client_profile: Some(&unsupported_video), - }, - ) - .expect_err("the client does not advertise the upstream video codec"); - assert!(matches!( - video_error, - super::ProviderError::ClientIncompatible { - required_capability: Some(ref capability), - .. - } if capability == "dash_video_codec_string" - )); +#[cfg(test)] +mod tests { + use super::{ + bilibili_dash_manifest_swarm_id, bilibili_dash_playback_infos, + bilibili_dash_resource_candidates, bilibili_dash_video_codec, bilibili_durl_media, + bilibili_durl_resource_candidates, bilibili_live_danmaku_track, + bilibili_live_playback_infos, bilibili_subtitle_track, bilibili_upstream, + bilibili_vod_danmaku_track, build_bilibili_durl_manifest, build_bilibili_mpd_manifest, + default_bilibili_live_mode, filter_bilibili_dash_manifest, + mark_bilibili_playback_resources, BilibiliDashPlaybackOptions, BilibiliProvider, + BilibiliSmsLoginSession, BilibiliSmsLoginTokenCodec, + }; + use crate::models::media::{ + BilibiliDashAudioStream, BilibiliDashManifest, BilibiliDashVideoStream, + BilibiliDurlSegment, BilibiliPlaybackKind, BilibiliPlaybackMetadata, + PlaybackBilibiliDanmaku, PlaybackBilibiliMedia, PlaybackBilibiliSubtitle, + PlaybackDanmakuProvider, PlaybackMediaProvider, PlaybackMetadata, PlaybackSubtitleProvider, + }; + use crate::models::{BilibiliTarget, PlaylistId, ProviderTarget, RoomId}; + use crate::provider::{ + InMemoryProviderStore, PlaybackAudioCodec, PlaybackClientEnvironment, + PlaybackClientProfile, PlaybackInfo, PlaybackMediaCapability, PlaybackMediaPipeline, + PlaybackMediaTransport, PlaybackResult, PlaybackTransportAction, PlaybackVideoCodec, + ProviderActor, ProviderContext, ProviderStore, ProviderStoreExt, SourceConfig, + VersionedPlayback, CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + }; + use std::collections::HashMap; + use std::sync::Arc; - let unsupported_audio = profile( - PlaybackVideoCodec::H264, - PlaybackAudioCodec::Eac3, - "avc1.64001F,ec-3", - ); - let mut metadata = - PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); - let audio_error = bilibili_dash_playback_infos( - &mut metadata, - "video:bvid:BV1test:cid:42", - &dash, - None, - BilibiliDashPlaybackOptions { - provider_instance_name: None, - subtitles: &[], - danmakus: &[], - client_profile: Some(&unsupported_audio), - }, - ) - .expect_err("the client does not advertise the upstream audio codec"); - assert!(matches!( - audio_error, - super::ProviderError::ClientIncompatible { - required_capability: Some(ref capability), - .. - } if capability == "dash_audio_codec_string" - )); + type TestResult = anyhow::Result; + + fn provider_ok(result: Result) -> TestResult { + result.map_err(|error| anyhow::anyhow!(error.to_string())) } - #[test] - fn unified_dash_keeps_one_proxy_route_and_a_content_level_swarm() { - let source = super::playback_media( - "DASH".to_string(), - "mpd".to_string(), - None, - Some("sm3_test_dash".to_string()), - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::Direct { - url: "https://cdn.example/source.mpd".to_string(), - headers: HashMap::new(), - }), - ); - let mut result = PlaybackResult { + fn web_hls_profile() -> PlaybackClientProfile { + PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: PlaybackClientEnvironment::Web, + supported_video_codecs: vec![PlaybackVideoCodec::H264], + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Hls, + container: None, + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Aac), + pipeline: PlaybackMediaPipeline::MediaSource, + codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()), + }], + supports_custom_http_headers: false, + supports_provider_proxy: true, + ..PlaybackClientProfile::default() + } + } + + fn durl_test_result() -> TestResult { + let media = provider_ok(bilibili_durl_media( + "MP4", + [( + "https://cdn.example/video.mp4?deadline=200".to_string(), + Vec::new(), + 1_000, + )], + "sm3_test_durl_web".to_string(), + ))?; + Ok(PlaybackResult { playback_infos: HashMap::from([( - "dash".to_string(), + "durl".to_string(), PlaybackInfo { thumbnail: None, - medias: vec![source], + medias: vec![media], default_media_index: Some(0), subtitles: Vec::new(), default_subtitle_index: None, @@ -5938,1452 +5888,1502 @@ mod tests { default_danmaku_index: None, }, )]), - default_mode: "dash".to_string(), + default_mode: "durl".to_string(), provider: crate::models::SourceProvider::Bilibili, - provider_instance_name: None, - duration_seconds: Some(10.0), - playback_kind: Some(crate::models::PlaybackKind::Regular), - metadata: Some(PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { - bvid: Some("BV1test".to_string()), - cid: Some(42), - dash_manifest: Some(BilibiliDashManifest { - video_streams: vec![ - BilibiliDashVideoStream { - id: 32, - codecs: "avc1.64001F".to_string(), - base_url: "https://cdn.example/dash.m4s?deadline=100".to_string(), - ..Default::default() - }, - BilibiliDashVideoStream { - id: 80, - codecs: "hev1.1.6.L120.90".to_string(), - base_url: "https://cdn.example/hevc.m4s?deadline=200".to_string(), - ..Default::default() - }, - ], - audio_streams: vec![BilibiliDashAudioStream { - id: 30_250, - codecs: "ec-3".to_string(), - base_url: "https://cdn.example/eac3.m4s?deadline=150".to_string(), - ..Default::default() - }], - ..Default::default() - }), - ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video) - })), - }; - let mut eac3_result = result.clone(); - - mark_bilibili_playback_resources( - &mut result, - "version", - 123, - crate::models::PlaybackProxyMode::Auto, - None, - ); - - assert_eq!(result.default_mode, "proxy_dash"); - assert!(!result.playback_infos.contains_key("dash")); - let proxied = &result.playback_infos["proxy_dash"].medias; - assert_eq!(proxied.len(), 1); - assert_eq!(proxied[0].name, "DASH"); - assert_eq!( - proxied[0].expire_at.map(|value| value.timestamp()), - Some(100) - ); - assert!(matches!( - &proxied[0].provider, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::ProxyDashManifest { - mode_name, - .. - }) if mode_name == "dash" - )); - assert_eq!( - proxied[0].p2p_swarm_id.as_deref(), - Some(bilibili_dash_manifest_swarm_id(None, "video:bvid:BV1test:cid:42").as_str()) - ); - - let profile = PlaybackClientProfile { - profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, - media_capabilities: vec![PlaybackMediaCapability { - transport: PlaybackMediaTransport::Dash, - container: Some(crate::provider::PlaybackContainer::Mp4), - video_codec: Some(PlaybackVideoCodec::H264), - audio_codec: Some(PlaybackAudioCodec::Eac3), - pipeline: PlaybackMediaPipeline::Native, - codec_string: Some("avc1.64001F,ec-3".to_string()), - }], - ..PlaybackClientProfile::default() - }; - let context = ProviderContext::new("test", ProviderActor::System) - .with_playback_client_profile(Some(profile)); - mark_bilibili_playback_resources( - &mut eac3_result, - "version", - 123, - crate::models::PlaybackProxyMode::Prefer, - Some(&context), - ); - - assert!(eac3_result.playback_infos.contains_key("dash")); - assert!(eac3_result.playback_infos.contains_key("proxy_dash")); - } - - #[test] - fn live_streams_group_quality_variants_by_cdn_host() -> TestResult { - let stream = |quality, quality_name: &str, host: &str, suffix: &str, expires_at| { - super::bilibili_upstream::LiveStream { - quality, - quality_name: quality_name.to_string(), - protocol: "http_hls".to_string(), - format: "fmp4".to_string(), - codec: "avc".to_string(), - urls: vec![super::bilibili_upstream::LiveStreamUrl { - host: host.to_string(), - url: format!("{host}/{suffix}.m3u8"), - expires_at, - }], - } - }; - let infos = provider_ok(bilibili_live_playback_infos( - vec![ - stream(400, "1080P", "https://main.example", "high", Some(400)), - stream(80, "流畅", "https://main.example", "low", Some(80)), - stream(400, "1080P", "https://backup.example", "high", Some(401)), - stream(80, "流畅", "https://backup.example", "low", None), - ], - &[], - ))?; - - assert_eq!(infos.len(), 2); - assert_eq!(infos["main"].medias.len(), 2); - assert_eq!(infos["main"].medias[0].name, "1080P"); - assert_eq!(infos["main"].medias[1].name, "流畅"); - assert_eq!( - infos["main"].medias[0] - .expire_at - .map(|value| value.timestamp()), - Some(400) - ); - assert_eq!( - infos["main"].medias[1] - .expire_at - .map(|value| value.timestamp()), - Some(80) - ); - assert_eq!(infos["backup_1"].medias.len(), 2); - assert_eq!( - infos["backup_1"].medias[0] - .expire_at - .map(|value| value.timestamp()), - Some(401) - ); - assert_eq!(infos["backup_1"].medias[1].expire_at, None); - Ok(()) - } - - #[test] - fn live_streams_preserve_hls_and_flv_formats() -> TestResult { - let stream = - |protocol: &str, format: &str, url: &str| super::bilibili_upstream::LiveStream { - quality: 400, - quality_name: "1080P".to_string(), - protocol: protocol.to_string(), - format: format.to_string(), - codec: "avc".to_string(), - urls: vec![super::bilibili_upstream::LiveStreamUrl { - host: format!("https://{format}.example"), - url: url.to_string(), - expires_at: None, - }], - }; - let infos = provider_ok(bilibili_live_playback_infos( - vec![ - stream("http_hls", "fmp4", "https://fmp4.example/live.m3u8"), - stream("http_stream", "flv", "https://flv.example/live.flv"), - ], - &[], - ))?; - - let formats = infos - .values() - .flat_map(|info| info.medias.iter().map(|media| media.format.as_str())) - .collect::>(); - assert!(formats.contains(&"m3u8")); - assert!(formats.contains(&"flv")); - Ok(()) + provider_instance_name: None, + duration_seconds: None, + playback_kind: Some(crate::models::PlaybackKind::Regular), + metadata: None, + }) } #[test] - fn live_playback_rejects_an_empty_upstream_stream_set() { - assert!(matches!( - bilibili_live_playback_infos(Vec::new(), &[]), - Err(super::ProviderError::NotFound) - )); + fn playback_cache_key_uses_the_codec_route_schema_version() -> TestResult { + let config = super::BilibiliSourceConfig::Video(crate::models::BilibiliVideoSourceConfig { + bvid: Some("BV1test12345".to_string()), + aid: None, + cid: 42, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, + }); + + let (key, _) = provider_ok(super::playback_cache_entry(&config, "anonymous", None))?; + + assert!(key.starts_with("playback:v10:video:")); + Ok(()) } #[test] - fn durl_manifest_rejects_line_breaks_in_segment_urls() { - let error = build_bilibili_durl_manifest(&[BilibiliDurlSegment { - url: "https://cdn.example/video.mp4".to_string(), - backup_urls: vec!["https://backup.example/video.mp4\n#EXT-X-ENDLIST".to_string()], - duration_millis: 1_000, - }]) - .expect_err("line breaks must not enter generated manifests"); - assert!(error.to_string().contains("line break")); - } -} + fn vod_playback_cache_is_partitioned_by_client_codec_capabilities() -> TestResult { + let config = super::BilibiliSourceConfig::Video(crate::models::BilibiliVideoSourceConfig { + bvid: Some("BV1test12345".to_string()), + aid: None, + cid: 42, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, + }); + let mut high_profile = web_hls_profile(); + high_profile.media_capabilities[0].transport = PlaybackMediaTransport::Dash; + high_profile.media_capabilities[0].codec_string = Some("avc1.640033,mp4a.40.2".to_string()); + let mut main_profile = high_profile.clone(); + main_profile.media_capabilities[0].codec_string = Some("avc1.64001F,mp4a.40.2".to_string()); -fn map_bilibili_live_danmaku_event( - event: bilibili_upstream::BilibiliLiveDanmakuEvent, -) -> super::BilibiliLiveDanmakuEvent { - let event_type = bilibili_upstream::BilibiliLiveDanmakuEventType::try_from(event.r#type) - .unwrap_or(bilibili_upstream::BilibiliLiveDanmakuEventType::Unspecified); - let kind = match event_type { - bilibili_upstream::BilibiliLiveDanmakuEventType::Unspecified => { - super::BilibiliLiveDanmakuEventKind::Unspecified - } - bilibili_upstream::BilibiliLiveDanmakuEventType::Chat => { - super::BilibiliLiveDanmakuEventKind::Chat - } - bilibili_upstream::BilibiliLiveDanmakuEventType::UserEnter => { - super::BilibiliLiveDanmakuEventKind::UserEnter - } - bilibili_upstream::BilibiliLiveDanmakuEventType::Gift => { - super::BilibiliLiveDanmakuEventKind::Gift - } - bilibili_upstream::BilibiliLiveDanmakuEventType::Heartbeat => { - super::BilibiliLiveDanmakuEventKind::Heartbeat - } - bilibili_upstream::BilibiliLiveDanmakuEventType::Unknown => { - super::BilibiliLiveDanmakuEventKind::Unknown - } - }; - super::BilibiliLiveDanmakuEvent { - format: event.format, - event_type: event.event_type, - kind, - user: event.user, - message: event.message, - timestamp: event.timestamp, - gift_name: event.gift_name, - gift_count: event.gift_count, - online_count: event.online_count, + let (high_key, _) = provider_ok(super::playback_cache_entry( + &config, + "anonymous", + Some(&high_profile), + ))?; + let (main_key, _) = provider_ok(super::playback_cache_entry( + &config, + "anonymous", + Some(&main_profile), + ))?; + + assert_ne!(high_key, main_key); + assert!(!high_key.contains("avc1")); + Ok(()) } -} -#[async_trait] -impl super::BilibiliLiveDanmakuProvider for BilibiliProvider { - async fn watch_bilibili_live_danmaku( - &self, - ctx: &ProviderContext<'_>, - source_config: &MediaSourceConfig, - ) -> Result { - let config = BilibiliSourceConfig::from_media_config(source_config)?; - let BilibiliSourceConfig::Live(config) = config else { - return Err(ProviderError::InvalidConfig( - "Bilibili live danmaku requires a live source config".to_string(), - )); - }; + #[test] + fn anonymous_access_uses_the_canonical_playback_cache_partition() { + let (cookies, partition) = + BilibiliProvider::anonymous_access().into_cookies_and_partition(); - let credential_user_id = bilibili_optional_credential_user_id( - ctx, - ProviderCredentialPolicy::from_shared(config.shared), - )?; - let (cookies, _) = resolve_optional_bilibili_cookies(ctx, credential_user_id).await?; - let client = self - .get_client_with_context(ctx.provider_instance_name(), ctx.request_context()) - .await?; - let stream = client - .watch_bilibili_live_danmaku(bilibili_upstream::WatchBilibiliLiveDanmakuReq { - cookies, - room_id: config.room_id, - }) - .await?; - let stream = stream.map(|event| { - event - .map(map_bilibili_live_danmaku_event) - .map_err(ProviderError::from) - }); - Ok(Box::pin(stream)) + assert!(cookies.is_empty()); + assert_eq!(partition, "anonymous"); } -} -impl BilibiliProvider { - pub async fn get_media_stream( - &self, - store: Option<&std::sync::Arc>, - version: &str, - mode_name: &str, - url_index: usize, - request_context: Option<&super::ExecutionControl>, - range_header: Option<&str>, - ) -> Result { - let versioned = - super::playback_transport::lookup_versioned(store, version, request_context).await?; - let playback_info = versioned - .result - .playback_infos - .get(mode_name) - .ok_or(ProviderError::NotFound)?; - let media = playback_info - .medias - .get(url_index) - .ok_or(ProviderError::NotFound)?; - let url = media.upstream_url().ok_or(ProviderError::NotFound)?; - let headers = media.upstream_headers(); - Ok( - super::playback_transport::PlaybackTransportAction::FetchAndForward { - url: url.to_string(), - headers: if headers.is_empty() { - bilibili_headers() - } else { - headers - }, - range_header: range_header.map(ToString::to_string), - proxy_strategy: super::PlaybackResourceProxyStrategy::SliceCache, - }, - ) - } + #[test] + fn playback_policy_covers_video_pgc_live_and_mixed_dynamic_playlists() -> TestResult { + let media_configs = [ + ( + crate::models::MediaSourceConfig::Bilibili(super::BilibiliSourceConfig::Video( + crate::models::BilibiliVideoSourceConfig { + bvid: Some("BV1test12345".to_string()), + aid: None, + cid: 1, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, + }, + )), + "video", + ), + ( + crate::models::MediaSourceConfig::Bilibili(super::BilibiliSourceConfig::Pgc( + crate::models::BilibiliPgcSourceConfig { + epid: 2, + cid: 3, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, + }, + )), + "pgc", + ), + ( + crate::models::MediaSourceConfig::Bilibili(super::BilibiliSourceConfig::Live( + crate::models::BilibiliLiveSourceConfig { + room_id: 4, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, + }, + )), + "live", + ), + ]; - pub async fn get_hls_manifest( - &self, - store: Option<&std::sync::Arc>, - version: &str, - mode_name: &str, - url_index: usize, - request_context: Option<&super::ExecutionControl>, - ) -> Result { - let versioned = - super::playback_transport::lookup_versioned(store, version, request_context).await?; - let playback_info = versioned - .result - .playback_infos - .get(mode_name) - .ok_or(ProviderError::NotFound)?; - let media = playback_info - .medias - .get(url_index) - .ok_or(ProviderError::NotFound)?; - if let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { - segments, - .. - }) = &media.provider - { - return Ok( - super::playback_transport::PlaybackTransportAction::M3u8DirectBody { - body: build_bilibili_durl_manifest(segments)?.into_bytes(), - }, + for (config, variant) in &media_configs { + let policy = provider_ok(super::bilibili_playback_proxy_policy(SourceConfig::media( + config, + )))?; + assert_eq!(policy.auto_policies.len(), 1); + assert_eq!(policy.auto_policies[0].variant, *variant); + assert_eq!( + policy.auto_policies[0].mode, + crate::models::PlaybackProxyMode::Only ); - } - if let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { - segments, - .. - }) = &media.provider - { - return Ok( - super::playback_transport::PlaybackTransportAction::M3u8BodyRewrite { - body: build_bilibili_durl_manifest(segments)?.into_bytes(), - }, + assert_eq!( + policy.auto_policies[0].reason, + crate::provider::PlaybackProxyAutoReason::SignedResource ); } - let url = media.upstream_url().ok_or(ProviderError::NotFound)?; - let headers = media.upstream_headers(); - Ok( - super::playback_transport::PlaybackTransportAction::M3u8Rewrite { - url: url.to_string(), - headers: if headers.is_empty() { - bilibili_headers() - } else { - headers + + let playlist = crate::models::PlaylistSourceConfig::Bilibili( + crate::models::BilibiliPlaylistSourceConfig { + source: crate::models::BilibiliPlaylistSource::History { + history_type: crate::models::BilibiliHistoryType::All, }, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, }, - ) + ); + let policy = provider_ok(super::bilibili_playback_proxy_policy( + SourceConfig::dynamic_playlist(&playlist), + ))?; + assert_eq!( + policy + .auto_policies + .iter() + .map(|policy| policy.variant.as_str()) + .collect::>(), + vec!["video", "pgc", "live"] + ); + assert!(policy.auto_policies.iter().all(|policy| { + policy.mode == crate::models::PlaybackProxyMode::Only + && policy.reason == crate::provider::PlaybackProxyAutoReason::SignedResource + })); + Ok(()) } - pub async fn get_hls_resource( - &self, - store: Option<&std::sync::Arc>, - request: BilibiliHlsResourceRequest<'_>, - request_context: Option<&super::ExecutionControl>, - ) -> Result { - let versioned = - super::playback_transport::lookup_versioned(store, request.version, request_context) - .await?; - let media = versioned - .result - .playback_infos - .get(request.mode_name) - .and_then(|info| info.medias.get(request.media_index)) - .ok_or(ProviderError::NotFound)?; - let headers = media.upstream_headers(); - let headers = if headers.is_empty() { - bilibili_headers() - } else { - headers - }; - if request.is_manifest { - Ok( - super::playback_transport::PlaybackTransportAction::M3u8Rewrite { - url: request.target_url.to_string(), - headers, - }, - ) - } else { - if matches!( - &media.provider, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { .. }) - ) { - let urls = bilibili_durl_resource_candidates(media, request.target_url) - .ok_or(ProviderError::NotFound)?; - return Ok( - super::playback_transport::PlaybackTransportAction::FetchAndForwardCandidates { - urls, - headers, - range_header: request.range_header.map(ToString::to_string), - }, - ); - } - Ok( - super::playback_transport::PlaybackTransportAction::FetchAndForward { - url: request.target_url.to_string(), - headers, - range_header: request.range_header.map(ToString::to_string), - proxy_strategy: super::PlaybackResourceProxyStrategy::SliceCache, - }, - ) - } + fn test_sms_login_secret() -> &'static [u8] { + b"test-bilibili-sms-login-secret" } - pub async fn get_dash_manifest( - &self, - store: Option<&std::sync::Arc>, - version: &str, - mode_name: &str, - manifest_mode: BilibiliDashManifestMode, - request_context: Option<&super::ExecutionControl>, - ) -> Result { - if mode_name != BILIBILI_DASH_MODE_NAME { - return Err(ProviderError::NotFound); - } - let versioned = - super::playback_transport::lookup_versioned(store, version, request_context).await?; - let dash = dash_manifest_from_metadata(&versioned.result)?; - let body = build_bilibili_mpd_manifest(&dash, |_index, url| url.to_string())?; - Ok(match manifest_mode { - BilibiliDashManifestMode::Direct => { - super::playback_transport::PlaybackTransportAction::DirectBody { - body: body.into_bytes(), - content_type: "application/dash+xml".to_string(), - status: 200, - } - } - BilibiliDashManifestMode::Proxy => { - super::playback_transport::PlaybackTransportAction::MpdBodyRewrite { - body: body.into_bytes(), - source_url: "https://synctv.invalid/bilibili-generated.mpd".to_string(), - } - } - }) - } + #[tokio::test] + async fn guest_playback_uses_anonymous_bilibili_access() -> TestResult { + let viewer_id = crate::models::UserId::expect_positive(7); + let context = ProviderContext::new("test", ProviderActor::Guest); - pub async fn get_dash_resource( - &self, - store: Option<&std::sync::Arc>, - request: BilibiliDashResourceRequest<'_>, - request_context: Option<&super::ExecutionControl>, - ) -> Result { - let target_url = super::playback_transport::resolve_dash_scope_target( - request.scope_url, - request.resource_path, - request.resource_query, - )?; - if request.is_manifest { - Ok( - super::playback_transport::PlaybackTransportAction::MpdRewrite { - url: target_url, - headers: bilibili_headers(), - }, - ) - } else { - if request.mode_name != BILIBILI_DASH_MODE_NAME { - return Err(ProviderError::NotFound); - } - let versioned = super::playback_transport::lookup_versioned( - store, - request.version, - request_context, - ) - .await?; - let dash = dash_manifest_from_metadata(&versioned.result)?; - let urls = bilibili_dash_resource_candidates( - &dash, - request.scope_url, - request.resource_path, - request.resource_query, - )? - .unwrap_or_else(|| vec![target_url]); - Ok( - super::playback_transport::PlaybackTransportAction::FetchAndForwardCandidates { - urls, - headers: bilibili_headers(), - range_header: request.range_header.map(ToString::to_string), - }, - ) - } - } + let (cookies, partition) = + provider_ok(super::resolve_optional_bilibili_cookies(&context, Some(viewer_id)).await)?; - pub async fn get_subtitle( - &self, - store: Option<&std::sync::Arc>, - version: &str, - mode_name: &str, - subtitle_index: usize, - request_context: Option<&super::ExecutionControl>, - ) -> Result { - let versioned = - super::playback_transport::lookup_versioned(store, version, request_context).await?; - let playback_info = versioned - .result - .playback_infos - .get(mode_name) - .ok_or(ProviderError::NotFound)?; - let subtitle = playback_info - .subtitles - .get(subtitle_index) - .ok_or(ProviderError::NotFound)?; - let media_headers = playback_info - .medias - .first() - .map_or_else(HashMap::new, PlaybackMedia::upstream_headers); - let headers = super::subtitle_headers_for_proxy(&media_headers, subtitle); - Ok( - super::playback_transport::PlaybackTransportAction::FetchAndForward { - url: subtitle.upstream_url().to_string(), - headers: if headers.is_empty() { - bilibili_headers() - } else { - headers - }, - range_header: None, - proxy_strategy: super::PlaybackResourceProxyStrategy::FullResponseCache, - }, - ) + assert!(cookies.is_empty()); + assert_eq!(partition, "guest-anonymous"); + Ok(()) } - pub async fn get_danmaku_file( - &self, - store: Option<&std::sync::Arc>, - version: &str, - danmaku_index: usize, - request_context: Option<&super::ExecutionControl>, - ) -> Result { - let versioned = - super::playback_transport::lookup_versioned(store, version, request_context).await?; - let playback_info = versioned - .result - .playback_infos - .get(&versioned.result.default_mode) - .ok_or(ProviderError::NotFound)?; - let danmaku = playback_info - .danmakus - .get(danmaku_index) - .ok_or(ProviderError::NotFound)?; - let url = danmaku.upstream_url().ok_or(ProviderError::NotFound)?; - let headers = danmaku.upstream_headers(); - Ok( - super::playback_transport::PlaybackTransportAction::FetchAndForward { - url: url.to_string(), - headers: if headers.is_empty() { - bilibili_headers() - } else { - headers - }, - range_header: None, - proxy_strategy: super::PlaybackResourceProxyStrategy::FullResponseCache, + #[test] + fn guest_public_playlist_has_no_credential_dependency() -> TestResult { + let creator_id = crate::models::UserId::expect_positive(8); + let context = + ProviderContext::new("test", ProviderActor::Guest).with_credential_owner_id(creator_id); + let config = crate::models::PlaylistSourceConfig::Bilibili( + crate::models::BilibiliPlaylistSourceConfig { + source: crate::models::BilibiliPlaylistSource::Popular, + shared: true, + proxy_mode: crate::models::PlaybackProxyMode::Auto, }, - ) - } -} + ); -use super::bilibili_headers; + let dependencies = provider_ok(super::bilibili_credential_dependencies( + &context, + super::SourceConfig::dynamic_playlist(&config), + ))?; + assert!(dependencies.is_empty()); + Ok(()) + } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BilibiliDashManifestMode { - Direct, - Proxy, -} + #[test] + fn guest_private_playlist_uses_creator_credential() -> TestResult { + let creator_id = crate::models::UserId::expect_positive(9); + let context = + ProviderContext::new("test", ProviderActor::Guest).with_credential_owner_id(creator_id); + let config = crate::models::PlaylistSourceConfig::Bilibili( + crate::models::BilibiliPlaylistSourceConfig { + source: crate::models::BilibiliPlaylistSource::WatchLater, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, + }, + ); -fn playback_media( - name: String, - format: String, - expires_at: Option, - p2p_swarm_id: Option, - provider: PlaybackMediaProvider, -) -> PlaybackMedia { - PlaybackMedia { - name, - format, - expire_at: expires_at.and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, 0)), - metadata: None, - p2p_swarm_id, - provider, + let dependencies = provider_ok(super::bilibili_credential_dependencies( + &context, + super::SourceConfig::dynamic_playlist(&config), + ))?; + assert_eq!(dependencies.len(), 1); + assert_eq!(dependencies[0].user_id, creator_id); + assert!(dependencies[0].requirement.is_required()); + Ok(()) } -} -fn bilibili_url_expiration_timestamp(value: &str) -> Option { - let bilibili_expiration = url::Url::parse(value).ok().and_then(|url| { - url.query_pairs() - .filter(|(key, _)| { - key.eq_ignore_ascii_case("deadline") || key.eq_ignore_ascii_case("expires") - }) - .filter_map(|(_, value)| value.parse::().ok()) - .filter(|expires_at| *expires_at > 0) - .min() - }); - super::url_expiration_timestamp(value) - .into_iter() - .chain(bilibili_expiration) - .min() -} + #[test] + fn sms_login_session_token_decodes_across_codecs() -> TestResult { + let codec_one = provider_ok(BilibiliSmsLoginTokenCodec::derive_from( + test_sms_login_secret(), + ))?; + let codec_two = provider_ok(BilibiliSmsLoginTokenCodec::derive_from( + test_sms_login_secret(), + ))?; + let session = BilibiliSmsLoginSession { + token: "captcha-token".to_string(), + challenge: "captcha-challenge".to_string(), + phone: Some("13800000000".to_string()), + captcha_key: Some("captcha-key".to_string()), + instance_name: Some("bilibili_remote".to_string()), + expires_at: crate::SystemClock.now().timestamp() + 60, + }; -fn bilibili_subtitle_track( - provider_instance_name: Option<&str>, - content_descriptor: &str, - name: &str, - url: String, -) -> PlaybackSubtitle { - let expire_at = bilibili_url_expiration_timestamp(&url) - .and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, 0)); - let url_identity = url::Url::parse(&url).map_or_else( - |_| url.split('?').next().unwrap_or_default().to_string(), - |url| { - format!( - "{}://{}{}", - url.scheme(), - url.host_str().unwrap_or_default(), - url.path() - ) - }, - ); - PlaybackSubtitle { - language: name.to_string(), - name: name.to_string(), - format: "json".to_string(), - p2p_swarm_id: Some(super::provider_p2p_swarm_id( - BilibiliProvider::NAME, - provider_instance_name, - "subtitle", - &format!("{content_descriptor}:track:{name}:url:{url_identity}"), - )), - provider: PlaybackSubtitleProvider::Bilibili(PlaybackBilibiliSubtitle::Direct { - url, - headers: bilibili_headers(), - expire_at, - }), - } -} + let encoded = provider_ok(codec_one.encode(&session))?; + assert!( + !encoded.contains("captcha-token") + && !encoded.contains("captcha-challenge") + && !encoded.contains("captcha-key") + && !encoded.contains("13800000000"), + "session token must keep Bilibili SMS login secrets and phone number encrypted" + ); + let decoded = provider_ok(codec_two.decode(&encoded))?; -fn dash_manifest_expiration(dash: &BilibiliDashManifest) -> Option { - dash.video_streams - .iter() - .flat_map(|stream| std::iter::once(&stream.base_url).chain(&stream.backup_urls)) - .chain( - dash.audio_streams - .iter() - .flat_map(|stream| std::iter::once(&stream.base_url).chain(&stream.backup_urls)), - ) - .filter_map(|url| bilibili_url_expiration_timestamp(url)) - .min() -} + assert_eq!(decoded.token, session.token); + assert_eq!(decoded.challenge, session.challenge); + assert_eq!(decoded.phone, session.phone); + assert_eq!(decoded.captcha_key, session.captcha_key); + assert_eq!(decoded.instance_name, session.instance_name); + Ok(()) + } -fn bilibili_video_content_descriptor(bvid: Option<&str>, aid: u64, cid: u64) -> String { - bvid.map_or_else( - || format!("video:aid:{aid}:cid:{cid}"), - |bvid| format!("video:bvid:{bvid}:cid:{cid}"), - ) -} + #[test] + fn sms_login_session_token_rejects_tampering_and_expiry() -> TestResult { + let codec = provider_ok(BilibiliSmsLoginTokenCodec::derive_from( + test_sms_login_secret(), + ))?; + let valid = BilibiliSmsLoginSession { + token: "captcha-token".to_string(), + challenge: "captcha-challenge".to_string(), + phone: None, + captcha_key: None, + instance_name: None, + expires_at: crate::SystemClock.now().timestamp() + 60, + }; + let expired = BilibiliSmsLoginSession { + expires_at: crate::SystemClock.now().timestamp() - 1, + ..valid.clone() + }; -fn bilibili_pgc_content_descriptor(epid: u64, cid: u64) -> String { - format!("pgc:epid:{epid}:cid:{cid}") -} + let encoded = provider_ok(codec.encode(&valid))?; + let mut tampered = encoded.clone(); + tampered.push('x'); -fn bilibili_content_descriptor(result: &PlaybackResult) -> Option { - let Some(PlaybackMetadata::Bilibili(metadata)) = result.metadata.as_ref() else { - return None; - }; - match metadata.kind { - BilibiliPlaybackKind::Video => Some(bilibili_video_content_descriptor( - metadata.bvid.as_deref(), - metadata.aid.unwrap_or_default(), - metadata.cid?, - )), - BilibiliPlaybackKind::Pgc => Some(bilibili_pgc_content_descriptor( - metadata.epid?, - metadata.cid?, - )), - BilibiliPlaybackKind::Live => None, + assert!(codec.decode(&tampered).is_err()); + let expired_token = provider_ok(codec.encode(&expired))?; + assert!(codec.decode(&expired_token).is_err()); + Ok(()) } -} -fn bilibili_dash_manifest_swarm_id( - provider_instance_name: Option<&str>, - content_descriptor: &str, -) -> String { - super::provider_p2p_swarm_id( - BilibiliProvider::NAME, - provider_instance_name, - "dash-media-set", - &format!("{content_descriptor}:schema:{BILIBILI_DASH_SWARM_SCHEMA_VERSION}"), - ) -} + #[test] + fn history_cursor_round_trips_as_an_opaque_cursor() -> TestResult { + let encoded = provider_ok(BilibiliProvider::encode_history_cursor(Some( + super::bilibili_upstream::HistoryCursor { + max: 77, + view_at: 123_456, + business: "archive".to_string(), + }, + )))? + .ok_or_else(|| anyhow::anyhow!("encoded cursor should exist"))?; + assert!(!encoded.contains("archive")); -fn bilibili_durl_media( - name: &str, - segments: impl IntoIterator, u64)>, - p2p_swarm_id: String, -) -> Result { - let segments = segments - .into_iter() - .map(|(url, backup_urls, duration_millis)| BilibiliDurlSegment { - url, - backup_urls, - duration_millis, - }) - .collect::>(); - if segments.is_empty() { - return Err(ProviderError::ApiError( - "Bilibili DURL playback is empty".to_string(), - )); - } - let expires_at = segments - .iter() - .flat_map(|segment| std::iter::once(&segment.url).chain(&segment.backup_urls)) - .filter_map(|url| bilibili_url_expiration_timestamp(url)) - .min(); - Ok(playback_media( - name.to_string(), - "m3u8".to_string(), - expires_at, - Some(p2p_swarm_id), - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { - version: String::new(), - expires_at: 0, - mode_name: "mp4".to_string(), - segments, - headers: bilibili_headers(), - }), - )) -} + let decoded = provider_ok(BilibiliProvider::decode_history_cursor(Some(&encoded)))? + .ok_or_else(|| anyhow::anyhow!("decoded cursor should exist"))?; + assert_eq!(decoded.max, 77); + assert_eq!(decoded.view_at, 123_456); + assert_eq!(decoded.business, "archive"); + Ok(()) + } -fn bilibili_durl_resource_candidates( - media: &PlaybackMedia, - target_url: &str, -) -> Option> { - let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { segments, .. }) = - &media.provider - else { - return None; - }; - let segment = segments.iter().find(|segment| segment.url == target_url)?; - let mut candidates = Vec::with_capacity(1 + segment.backup_urls.len()); - for candidate in std::iter::once(&segment.url).chain(&segment.backup_urls) { - if !candidate.trim().is_empty() && !candidates.contains(candidate) { - candidates.push(candidate.clone()); - } + #[test] + fn dynamic_items_keep_provider_owned_metadata() { + let target = ProviderTarget::Bilibili(BilibiliTarget::Live { + room_id: 21_292_831, + }); + let Some(PlaybackMetadata::Bilibili(metadata)) = + BilibiliProvider::directory_metadata(&target) + else { + panic!("expected Bilibili metadata"); + }; + assert_eq!(metadata.kind, BilibiliPlaybackKind::Live); + assert_eq!(metadata.room_id, Some(21_292_831)); } - (!candidates.is_empty()).then_some(candidates) -} -fn build_bilibili_durl_manifest(segments: &[BilibiliDurlSegment]) -> Result { - if segments.is_empty() { - return Err(ProviderError::NotFound); + #[test] + fn live_playback_prefers_the_main_route() { + let playback_info = || super::PlaybackInfo { + thumbnail: None, + medias: Vec::new(), + default_media_index: None, + subtitles: Vec::new(), + default_subtitle_index: None, + danmakus: Vec::new(), + default_danmaku_index: None, + }; + let playback_infos = [ + ("backup_1".to_string(), playback_info()), + ("main".to_string(), playback_info()), + ] + .into_iter() + .collect(); + + assert_eq!(default_bilibili_live_mode(&playback_infos), "main"); } - if segments.iter().any(|segment| { - std::iter::once(&segment.url) - .chain(&segment.backup_urls) - .any(|url| url.contains('\r') || url.contains('\n')) - }) { - return Err(ProviderError::ApiError( - "Bilibili DURL segment URL contains a line break".to_string(), + + #[test] + fn dynamic_live_playback_has_a_live_danmaku_track() { + let room_id = RoomId::new(); + let playlist_id = PlaylistId::new(); + let context = ProviderContext::new("test", ProviderActor::Guest) + .with_room_id(room_id) + .with_playlist_id(playlist_id); + let track = bilibili_live_danmaku_track(&context, 21_292_831) + .expect("dynamic live playback should expose a danmaku track"); + + assert!(matches!( + track.provider, + crate::models::media::PlaybackDanmakuProvider::Bilibili( + crate::models::media::PlaybackBilibiliDanmaku::DynamicLive { + room_id: track_room_id, + playlist_id: track_playlist_id, + live_room_id: 21_292_831, + } + ) if track_room_id == room_id && track_playlist_id == playlist_id )); } - let target_duration = segments - .iter() - .map(|segment| segment.duration_millis.div_ceil(1_000)) - .max() - .unwrap_or(1) - .max(1); - let mut manifest = format!( - "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:{target_duration}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n" - ); - for (index, segment) in segments.iter().enumerate() { - if index > 0 { - manifest.push_str("#EXT-X-DISCONTINUITY\n"); - } - let duration_seconds = segment.duration_millis / 1_000; - let duration_millis = segment.duration_millis % 1_000; - let _ = writeln!(manifest, "#EXTINF:{duration_seconds}.{duration_millis:03},"); - let _ = writeln!(manifest, "{}", segment.url); - } - manifest.push_str("#EXT-X-ENDLIST\n"); - Ok(manifest) -} -fn bilibili_live_headers() -> HashMap { - let mut headers = bilibili_headers(); - headers.insert( - "Referer".to_string(), - "https://live.bilibili.com".to_string(), - ); - headers -} + #[test] + fn cached_dynamic_live_playback_attaches_danmaku_to_proxy_route() { + let room_id = RoomId::new(); + let playlist_id = PlaylistId::new(); + let context = ProviderContext::new("test", ProviderActor::Guest) + .with_room_id(room_id) + .with_playlist_id(playlist_id); + let mut result = PlaybackResult { + playback_infos: HashMap::from([( + "main".to_string(), + PlaybackInfo { + thumbnail: None, + medias: vec![super::playback_media( + "Live HLS".to_string(), + "m3u8".to_string(), + Some(456), + None, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::Direct { + url: "https://live.example.com/main.m3u8".to_string(), + headers: HashMap::new(), + }), + )], + default_media_index: Some(0), + subtitles: Vec::new(), + default_subtitle_index: None, + danmakus: Vec::new(), + default_danmaku_index: None, + }, + )]), + default_mode: "main".to_string(), + provider: crate::models::SourceProvider::Bilibili, + provider_instance_name: None, + duration_seconds: None, + playback_kind: Some(crate::models::PlaybackKind::Live), + metadata: Some(PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { + room_id: Some(21_292_831), + is_live: true, + is_currently_live: Some(true), + ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Live) + })), + }; -fn bilibili_live_danmaku_track( - ctx: &ProviderContext<'_>, - live_room_id: u64, -) -> Option { - let room_id = ctx.room_id()?; - let provider = match (ctx.media_id(), ctx.playlist_id()) { - (Some(media_id), _) => PlaybackBilibiliDanmaku::Live { - room_id: *room_id, - media_id: *media_id, - }, - (None, Some(playlist_id)) => PlaybackBilibiliDanmaku::DynamicLive { - room_id: *room_id, - playlist_id: *playlist_id, - live_room_id, - }, - (None, None) => return None, - }; - Some(PlaybackDanmaku { - name: LIVE_DANMAKU_TRACK_NAME.to_string(), - format: Some(LIVE_DANMAKU_FORMAT.to_string()), - p2p_swarm_id: None, - provider: PlaybackDanmakuProvider::Bilibili(provider), - }) -} + mark_bilibili_playback_resources( + &mut result, + "version", + 123, + crate::models::PlaybackProxyMode::Auto, + Some(&context), + ); -fn bilibili_vod_danmaku_track( - provider_instance_name: Option<&str>, - content_descriptor: &str, - cid: u64, -) -> PlaybackDanmaku { - PlaybackDanmaku { - name: "Bilibili danmaku".to_string(), - format: Some("xml".to_string()), - p2p_swarm_id: Some(super::provider_p2p_swarm_id( - BilibiliProvider::NAME, - provider_instance_name, - "danmaku", - &format!("{content_descriptor}:cid:{cid}"), - )), - provider: PlaybackDanmakuProvider::Bilibili(PlaybackBilibiliDanmaku::FileDirect { - url: format!("https://api.bilibili.com/x/v1/dm/list.so?oid={cid}"), - headers: bilibili_headers(), - expire_at: None, - }), + let info = &result.playback_infos["proxy_main"]; + assert_eq!(info.default_danmaku_index, Some(0)); + assert!(matches!( + info.danmakus.as_slice(), + [crate::models::media::PlaybackDanmaku { + provider: PlaybackDanmakuProvider::Bilibili( + PlaybackBilibiliDanmaku::DynamicLive { + room_id: track_room_id, + playlist_id: track_playlist_id, + live_room_id: 21_292_831, + } + ), + .. + }] if *track_room_id == room_id && *track_playlist_id == playlist_id + )); } -} -fn bilibili_dash_video_url_request( - cookies: &HashMap, - aid: u64, - bvid: String, - cid: u64, -) -> bilibili_upstream::GetDashVideoUrlReq { - bilibili_upstream::GetDashVideoUrlReq { - aid, - bvid, - cid, - cookies: cookies.clone(), - } -} + #[test] + fn live_playback_uses_the_transport_advertised_by_the_client() -> TestResult { + let hls_profile = super::super::PlaybackClientProfile { + media_capabilities: vec![super::super::PlaybackMediaCapability { + transport: super::super::PlaybackMediaTransport::Hls, + container: None, + video_codec: None, + audio_codec: None, + pipeline: super::super::PlaybackMediaPipeline::Native, + codec_string: None, + }], + ..Default::default() + }; + assert!(super::bilibili_live_uses_hls(Some(&hls_profile))); + + let native_profile = super::super::PlaybackClientProfile { + supported_live_transports: vec![super::super::PlaybackLiveTransport::Flv], + media_capabilities: vec![super::super::PlaybackMediaCapability { + transport: super::super::PlaybackMediaTransport::Flv, + container: None, + video_codec: None, + audio_codec: None, + pipeline: super::super::PlaybackMediaPipeline::Native, + codec_string: None, + }], + ..Default::default() + }; + assert!(!super::bilibili_live_uses_hls(Some(&native_profile))); -fn bilibili_video_url_request( - cookies: &HashMap, - aid: u64, - bvid: String, - cid: u64, - quality: u64, -) -> bilibili_upstream::GetVideoUrlReq { - bilibili_upstream::GetVideoUrlReq { - aid, - bvid, - cid, - quality, - cookies: cookies.clone(), - } -} + let web_flv_profile = super::super::PlaybackClientProfile { + profile_version: super::super::CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: super::super::PlaybackClientEnvironment::Web, + media_capabilities: vec![super::super::PlaybackMediaCapability { + transport: super::super::PlaybackMediaTransport::Flv, + container: None, + video_codec: Some(super::super::PlaybackVideoCodec::H264), + audio_codec: Some(super::super::PlaybackAudioCodec::Aac), + pipeline: super::super::PlaybackMediaPipeline::MediaSource, + codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()), + }], + supported_live_transports: Vec::new(), + ..Default::default() + }; + assert!(!super::bilibili_live_uses_hls(Some(&web_flv_profile))); -fn bilibili_subtitles_request( - cookies: &HashMap, - aid: u64, - bvid: String, - cid: u64, -) -> bilibili_upstream::GetSubtitlesReq { - bilibili_upstream::GetSubtitlesReq { - aid, - bvid, - cid, - cookies: cookies.clone(), + let config = super::BilibiliSourceConfig::Live(crate::models::BilibiliLiveSourceConfig { + room_id: 42, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, + }); + let (hls_key, _) = provider_ok(super::playback_cache_entry( + &config, + "anonymous", + Some(&hls_profile), + ))?; + let (flv_key, _) = provider_ok(super::playback_cache_entry( + &config, + "anonymous", + Some(&native_profile), + ))?; + let (web_flv_key, _) = provider_ok(super::playback_cache_entry( + &config, + "anonymous", + Some(&web_flv_profile), + ))?; + assert!(hls_key.ends_with(":hls")); + assert!(flv_key.ends_with(":flv")); + assert!(web_flv_key.ends_with(":flv")); + assert_ne!(hls_key, flv_key); + Ok(()) } -} -fn bilibili_dash_pgc_url_request( - cookies: &HashMap, - epid: u64, - cid: u64, -) -> bilibili_upstream::GetDashPgcurlReq { - bilibili_upstream::GetDashPgcurlReq { - epid, - cid, - cookies: cookies.clone(), - } -} + #[test] + fn durl_segments_are_exposed_as_one_ordered_vod_manifest() -> TestResult { + let media = provider_ok(bilibili_durl_media( + "MP4", + [ + ( + "https://cdn.example/part-1.mp4?deadline=200".to_string(), + vec!["https://backup.example/part-1.mp4?deadline=75".to_string()], + 1_250, + ), + ( + "https://cdn.example/part-2.mp4?deadline=100".to_string(), + Vec::new(), + 2_500, + ), + ], + "sm3_test_durl".to_string(), + ))?; + let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { + segments, + .. + }) = &media.provider + else { + anyhow::bail!("DURL media should use a direct in-memory manifest"); + }; -fn bilibili_pgc_url_request( - cookies: &HashMap, - epid: u64, - cid: u64, - quality: u64, -) -> bilibili_upstream::GetPgcurlReq { - bilibili_upstream::GetPgcurlReq { - epid, - cid, - quality, - cookies: cookies.clone(), + let manifest = provider_ok(build_bilibili_durl_manifest(segments))?; + assert_eq!(media.expire_at.map(|value| value.timestamp()), Some(75)); + assert_eq!( + bilibili_durl_resource_candidates( + &media, + "https://cdn.example/part-1.mp4?deadline=200" + ), + None + ); + let restored: crate::models::PlaybackMedia = + serde_json::from_value(serde_json::to_value(&media)?)?; + assert_eq!(media.p2p_swarm_id.as_deref(), Some("sm3_test_durl")); + assert_eq!(restored.p2p_swarm_id, media.p2p_swarm_id); + assert!(matches!( + restored.provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) + )); + assert_eq!(manifest.matches("#EXTINF:").count(), 2); + assert_eq!(manifest.matches("#EXT-X-DISCONTINUITY").count(), 1); + assert!(manifest.contains("#EXT-X-TARGETDURATION:3")); + assert!(manifest.contains("#EXTINF:1.250,\nhttps://cdn.example/part-1.mp4")); + assert!(manifest.contains("#EXTINF:2.500,\nhttps://cdn.example/part-2.mp4")); + assert!(manifest.ends_with("#EXT-X-ENDLIST\n")); + Ok(()) } -} -fn bilibili_live_streams_request( - cookies: HashMap, - room_id: u64, - hls: bool, -) -> bilibili_upstream::GetLiveStreamsReq { - bilibili_upstream::GetLiveStreamsReq { - cid: room_id, - hls, - cookies, + #[test] + fn durl_media_expiry_is_derived_only_from_upstream_urls() -> TestResult { + let undated = provider_ok(bilibili_durl_media( + "MP4", + [( + "https://cdn.example/video.mp4".to_string(), + Vec::new(), + 1_000, + )], + "sm3_test_durl_undated".to_string(), + ))?; + assert_eq!(undated.expire_at, None); + + let upstream_expires_at = 4_102_444_800; + let dated = provider_ok(bilibili_durl_media( + "MP4", + [( + format!("https://cdn.example/video.mp4?deadline={upstream_expires_at}"), + Vec::new(), + 1_000, + )], + "sm3_test_durl_dated".to_string(), + ))?; + assert_eq!( + dated.expire_at.map(|value| value.timestamp()), + Some(upstream_expires_at) + ); + Ok(()) } -} -fn bilibili_live_uses_hls(profile: Option<&super::PlaybackClientProfile>) -> bool { - !profile.is_some_and(|profile| { - profile.supports_transport(super::PlaybackMediaTransport::Flv) - && !profile.supports_transport(super::PlaybackMediaTransport::Hls) - }) -} + #[test] + fn dash_resources_keep_server_side_backup_candidates() -> TestResult { + let primary = "https://primary.example/video.m4s?deadline=200"; + let backup = "https://backup.example/video.m4s?deadline=200"; + let dash = BilibiliDashManifest { + video_streams: vec![BilibiliDashVideoStream { + base_url: primary.to_string(), + backup_urls: vec![backup.to_string(), backup.to_string()], + ..Default::default() + }], + ..Default::default() + }; -fn bilibili_live_transport_cache_token( - profile: Option<&super::PlaybackClientProfile>, -) -> &'static str { - if bilibili_live_uses_hls(profile) { - "hls" - } else { - "flv" + assert_eq!( + provider_ok(bilibili_dash_resource_candidates(&dash, primary, "", None))?, + Some(vec![primary.to_string(), backup.to_string()]) + ); + assert_eq!( + provider_ok(bilibili_dash_resource_candidates(&dash, backup, "", None))?, + Some(vec![backup.to_string(), primary.to_string()]) + ); + assert_eq!( + provider_ok(bilibili_dash_resource_candidates( + &dash, + "https://unknown.example/video.m4s", + "", + None + ))?, + None + ); + Ok(()) } -} - -impl BilibiliProvider { - /// Resolve playback result from Bilibili API (no caching). - /// Cookies are resolved from the credential store, not from source_config. - async fn resolve_from_api_with_cookies( - &self, - ctx: &ProviderContext<'_>, - config: &BilibiliSourceConfig, - cookies: &HashMap, - provider_instance_name: Option<&str>, - request_context: Option<&super::ExecutionControl>, - ) -> Result { - let sanitized_cookies = cookies.clone(); - let client = self - .get_client_with_context(provider_instance_name, request_context) - .await?; - let mode_info = |medias: Vec, - subtitles: Vec, - danmakus: Vec| { - let default_danmaku_index = (!danmakus.is_empty()).then_some(0); - PlaybackInfo { - thumbnail: None, - medias, - default_media_index: None, - subtitles, - default_subtitle_index: None, - danmakus, - default_danmaku_index, - } + #[test] + fn durl_manifest_keeps_a_proxy_sibling_for_proxy_only_mode() -> TestResult { + let media = provider_ok(bilibili_durl_media( + "MP4", + [( + "https://cdn.example/video.mp4?deadline=200".to_string(), + Vec::new(), + 1_000, + )], + "sm3_test_durl_proxy".to_string(), + ))?; + let mut result = PlaybackResult { + playback_infos: HashMap::from([( + "durl".to_string(), + PlaybackInfo { + thumbnail: None, + medias: vec![media], + default_media_index: Some(0), + subtitles: vec![bilibili_subtitle_track( + None, + "video:BV1test:cid:1", + "Chinese", + "https://subtitle.bilibili.com/subtitle.json?deadline=200".to_string(), + )], + default_subtitle_index: Some(0), + danmakus: vec![bilibili_vod_danmaku_track(None, "video:BV1test:cid:1", 1)], + default_danmaku_index: Some(0), + }, + )]), + default_mode: "durl".to_string(), + provider: crate::models::SourceProvider::Bilibili, + provider_instance_name: None, + duration_seconds: None, + playback_kind: Some(crate::models::PlaybackKind::Regular), + metadata: None, }; - match config { - BilibiliSourceConfig::Video(config) => { - let (bvid, aid) = - resolve_bilibili_video_identifier(config.bvid.as_deref(), config.aid)?; - let request_bvid = bvid.clone().unwrap_or_default(); - let cid = config.cid; - let content_descriptor = - bilibili_video_content_descriptor(bvid.as_deref(), aid, cid); + let direct_only_base = result.clone(); + mark_bilibili_playback_resources( + &mut result, + "version", + 123, + crate::models::PlaybackProxyMode::Auto, + None, + ); + assert_eq!(result.default_mode, "proxy_durl"); + assert!(result.playback_infos.contains_key("proxy_durl")); + assert!(!result.playback_infos.contains_key("durl")); + assert!(matches!( + result.playback_infos["proxy_durl"].medias[0].provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { .. }) + )); + assert!(matches!( + result.playback_infos["proxy_durl"].subtitles[0].provider, + PlaybackSubtitleProvider::Bilibili(PlaybackBilibiliSubtitle::Proxy { .. }) + )); + assert!(matches!( + result.playback_infos["proxy_durl"].danmakus[0].provider, + PlaybackDanmakuProvider::Bilibili(PlaybackBilibiliDanmaku::FileProxy { .. }) + )); - let request = bilibili_dash_video_url_request( - &sanitized_cookies, - aid, - request_bvid.clone(), - cid, - ); - let dash_resp = client.get_dash_video_url(request).await; - let dash_resp = match dash_resp { - Ok(dash_resp) if dash_resp.dash.is_some() => Some(dash_resp), - Ok(_) => None, - Err(error) if is_bilibili_video_dash_unavailable(&error) => None, - Err(error) => return Err(error.into()), - }; + let mut direct_only = direct_only_base.clone(); + mark_bilibili_playback_resources( + &mut direct_only, + "version", + 123, + crate::models::PlaybackProxyMode::DirectOnly, + None, + ); + assert_eq!(direct_only.default_mode, "durl"); + assert!(matches!( + direct_only.playback_infos["durl"].medias[0].provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) + )); + assert!(!direct_only.playback_infos.contains_key("proxy_durl")); + assert!(matches!( + direct_only.playback_infos["durl"].medias[0].provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) + )); - let mut metadata = PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { - bvid, - aid: Some(aid), - cid: Some(cid), - ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video) - }); - let mut subtitles = Vec::new(); + let mut proxy_only = direct_only_base; + mark_bilibili_playback_resources( + &mut proxy_only, + "version", + 123, + crate::models::PlaybackProxyMode::Only, + None, + ); + assert_eq!(proxy_only.default_mode, "proxy_durl"); + assert!(!proxy_only.playback_infos.contains_key("durl")); + assert_eq!(proxy_only.playback_infos["proxy_durl"].medias.len(), 1); + assert!(matches!( + proxy_only.playback_infos["proxy_durl"].subtitles[0].provider, + PlaybackSubtitleProvider::Bilibili(PlaybackBilibiliSubtitle::Proxy { .. }) + )); + assert!(matches!( + proxy_only.playback_infos["proxy_durl"].danmakus[0].provider, + PlaybackDanmakuProvider::Bilibili(PlaybackBilibiliDanmaku::FileProxy { .. }) + )); + Ok(()) + } - let subtitle_request = - bilibili_subtitles_request(&sanitized_cookies, aid, request_bvid.clone(), cid); - match client.get_subtitles(subtitle_request).await { - Ok(subtitle_resp) => { - subtitles = subtitle_resp - .subtitles - .into_iter() - .map(|(name, url)| { - bilibili_subtitle_track( - provider_instance_name, - &content_descriptor, - &name, - url, - ) - }) - .collect(); - } - Err(e) => { - tracing::warn!( - bvid = %request_bvid, aid = %aid, cid = %cid, error = %e, - "Failed to fetch Bilibili subtitles for video, continuing without subtitles" - ); - } - } + #[test] + fn web_durl_with_required_headers_only_exposes_the_proxy_route() -> TestResult { + let profile = web_hls_profile(); + let context = ProviderContext::new("test", ProviderActor::System) + .with_playback_client_profile(Some(profile)); + let mut result = durl_test_result()?; - let duration_seconds = dash_resp - .as_ref() - .and_then(|resp| resp.dash.as_ref()) - .map(|dash| dash.duration); - if let Some(dash) = dash_resp.as_ref().and_then(|resp| resp.dash.as_ref()) { - if let Some(metadata) = metadata.as_bilibili_mut() { - metadata.min_buffer_time = Some(dash.min_buffer_time); - } - } + mark_bilibili_playback_resources( + &mut result, + "version", + 123, + crate::models::PlaybackProxyMode::DirectPrefer, + Some(&context), + ); - let mut playback_infos = HashMap::new(); - let danmakus = vec![bilibili_vod_danmaku_track( - provider_instance_name, - &content_descriptor, - cid, - )]; + assert_eq!(result.default_mode, "proxy_durl"); + assert!(!result.playback_infos.contains_key("durl")); + assert!(result.playback_infos.contains_key("proxy_durl")); + Ok(()) + } - let default_mode = if let Some(dash_resp) = dash_resp { - let dash = dash_resp.dash.as_ref().ok_or_else(|| { - ProviderError::ApiError( - "Bilibili video playback response did not include DASH streams" - .to_string(), - ) - })?; - let primary_dash = dash_manifest_from_upstream(dash); - let hevc_dash = dash_resp - .hevc_dash - .as_ref() - .filter(|dash| !dash.video_streams.is_empty()) - .map(dash_manifest_from_upstream); - let (dash_infos, default_mode) = bilibili_dash_playback_infos( - &mut metadata, - &content_descriptor, - &primary_dash, - hevc_dash.as_ref(), - BilibiliDashPlaybackOptions { - provider_instance_name, - subtitles: &subtitles, - danmakus: &danmakus, - client_profile: ctx.playback_client_profile(), - }, - )?; - playback_infos.extend(dash_infos); - default_mode - } else { - let request = - bilibili_video_url_request(&sanitized_cookies, aid, request_bvid, cid, 80); - let video_resp = client.get_video_url(request).await?; - if let Some(metadata) = metadata.as_bilibili_mut() { - metadata.fallback_format = Some("durl".to_string()); - metadata.quality = Some(video_resp.current_quality); - } - let durl_swarm_id = super::provider_p2p_swarm_id( - Self::NAME, - provider_instance_name, - "media", - &format!( - "{content_descriptor}:durl:quality:{}", - video_resp.current_quality - ), - ); - playback_infos.insert( - "mp4".to_string(), - mode_info( - vec![bilibili_durl_media( - "MP4", - video_resp.segments.into_iter().map(|segment| { - (segment.url, segment.backup_urls, segment.duration_millis) - }), - durl_swarm_id, - )?], - subtitles, - danmakus, - ), - ); - "mp4".to_string() - }; + #[test] + fn web_direct_only_durl_returns_structured_client_incompatibility() -> TestResult { + let profile = web_hls_profile(); + let context = ProviderContext::new("test", ProviderActor::System) + .with_playback_client_profile(Some(profile.clone())); + let mut result = durl_test_result()?; + mark_bilibili_playback_resources( + &mut result, + "version", + 123, + crate::models::PlaybackProxyMode::DirectOnly, + Some(&context), + ); + + let error = super::super::require_compatible_playback_route( + result, + crate::models::PlaybackProxyMode::DirectOnly, + Some(&profile), + ) + .expect_err("DirectOnly cannot satisfy browser header restrictions"); + assert!(matches!( + error, + super::ProviderError::ClientIncompatible { + required_capability: Some(ref capability), + .. + } if capability == "browser_direct_media_access_or_provider_proxy" + )); + Ok(()) + } - Ok(PlaybackResult { - playback_infos, - default_mode, - provider: crate::models::SourceProvider::Bilibili, - provider_instance_name: provider_instance_name.map(str::to_string), - duration_seconds, - playback_kind: Some(crate::models::PlaybackKind::Regular), - metadata: Some(metadata), - }) - } + #[tokio::test] + async fn bilibili_subtitle_uses_full_response_cache_transport() -> TestResult { + let store: Arc = Arc::new(InMemoryProviderStore::new(8)); + let subtitle_url = "https://subtitle.bilibili.com/track.json"; + let playback = PlaybackResult { + playback_infos: HashMap::from([( + "proxy_dash".to_string(), + PlaybackInfo { + thumbnail: None, + medias: Vec::new(), + default_media_index: None, + subtitles: vec![bilibili_subtitle_track( + None, + "video:BV1test:cid:1", + "Chinese", + subtitle_url.to_string(), + )], + default_subtitle_index: Some(0), + danmakus: Vec::new(), + default_danmaku_index: None, + }, + )]), + default_mode: "proxy_dash".to_string(), + provider: crate::models::SourceProvider::Bilibili, + provider_instance_name: None, + duration_seconds: None, + playback_kind: Some(crate::models::PlaybackKind::Regular), + metadata: None, + }; + store + .set( + "v:subtitle-test", + &VersionedPlayback { + version: "subtitle-test".to_string(), + result: playback, + expires_at: crate::SystemClock.now().timestamp() + 60, + playback_context: None, + }, + std::time::Duration::from_secs(60), + ) + .await?; - BilibiliSourceConfig::Pgc(config) => { - let epid = config.epid; - let cid = config.cid; - let content_descriptor = bilibili_pgc_content_descriptor(epid, cid); + let provider = BilibiliProvider::new_local_only()?; + let action = provider + .get_subtitle(Some(&store), "subtitle-test", "proxy_dash", 0, None) + .await?; - let request = bilibili_dash_pgc_url_request(&sanitized_cookies, epid, cid); - let first_dash_resp = client.get_dash_pgcurl(request.clone()).await; - let dash_resp = if matches!( - &first_dash_resp, - Ok(response) if response.dash.is_none() - ) || matches!( - &first_dash_resp, - Err(error) if is_bilibili_pgc_dash_unavailable(error) - ) { - tracing::warn!( - epid, - cid, - "Bilibili PGC DASH response was empty; retrying once before DURL fallback" - ); - tokio::time::sleep(BILIBILI_DASH_RETRY_DELAY).await; - client.get_dash_pgcurl(request).await - } else { - first_dash_resp - }; - let dash_resp = match dash_resp { - Ok(dash_resp) if dash_resp.dash.is_some() => Some(dash_resp), - Ok(_) => None, - Err(error) if is_bilibili_pgc_dash_unavailable(&error) => None, - Err(error) => return Err(error.into()), - }; + let PlaybackTransportAction::FetchAndForward { + url, + headers, + range_header, + proxy_strategy, + } = action + else { + anyhow::bail!("Bilibili subtitles must use the full response cache transport"); + }; + assert_eq!(url, subtitle_url); + assert_eq!(range_header, None); + assert_eq!( + proxy_strategy, + crate::provider::PlaybackResourceProxyStrategy::FullResponseCache + ); + assert_eq!( + headers.get("Referer").map(String::as_str), + Some("https://www.bilibili.com") + ); + assert!(headers.contains_key("User-Agent")); + assert!( + !headers + .keys() + .any(|name| name.eq_ignore_ascii_case("range")), + "Bilibili subtitle provider headers must leave Range ownership to the transport layer" + ); + Ok(()) + } - let mut metadata = PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { - epid: Some(epid), - cid: Some(cid), - ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Pgc) - }); - let mut subtitles = Vec::new(); + #[test] + fn dash_video_codec_family_uses_the_exact_codec_string() { + let regular = bilibili_upstream::DashInfo { + video_streams: vec![bilibili_upstream::VideoStream::default()], + ..Default::default() + }; + let hevc = bilibili_upstream::DashInfo { + video_streams: vec![bilibili_upstream::VideoStream { + codecs: "hev1.1.6.L120.90".to_string(), + ..Default::default() + }], + ..Default::default() + }; - let subtitle_request = - bilibili_subtitles_request(&sanitized_cookies, 0, String::new(), cid); - match client.get_subtitles(subtitle_request).await { - Ok(subtitle_resp) => { - subtitles = subtitle_resp - .subtitles - .into_iter() - .map(|(name, url)| { - bilibili_subtitle_track( - provider_instance_name, - &content_descriptor, - &name, - url, - ) - }) - .collect(); - } - Err(e) => { - tracing::warn!( - epid = %epid, cid = %cid, error = %e, - "Failed to fetch Bilibili subtitles for PGC content, continuing without subtitles" - ); - } - } + assert_eq!( + bilibili_dash_video_codec(®ular.video_streams[0].codecs), + PlaybackVideoCodec::H264 + ); + assert_eq!( + bilibili_dash_video_codec(&hevc.video_streams[0].codecs), + PlaybackVideoCodec::Hevc + ); + assert_eq!( + bilibili_dash_video_codec("vp09.00.21.08"), + PlaybackVideoCodec::Vp9 + ); + } + + #[test] + fn dash_codecs_are_exposed_in_one_playback_mode() -> TestResult { + let video = |id: u64, codecs: &str, url: &str| BilibiliDashVideoStream { + id, + codecs: codecs.to_string(), + base_url: url.to_string(), + ..Default::default() + }; + let dash = BilibiliDashManifest { + video_streams: vec![ + video( + 1, + "avc1.640033", + "https://cdn.example/h264.m4s?deadline=100", + ), + video( + 2, + "av01.0.08M.08", + "https://cdn.example/av1.m4s?deadline=100", + ), + video( + 3, + "hev1.1.6.L120.90", + "https://cdn.example/hevc.m4s?deadline=100", + ), + ], + audio_streams: vec![ + BilibiliDashAudioStream { + id: 30_280, + codecs: "mp4a.40.2".to_string(), + base_url: "https://cdn.example/aac.m4s?deadline=100".to_string(), + ..Default::default() + }, + BilibiliDashAudioStream { + id: 30_250, + codecs: "ec-3".to_string(), + base_url: "https://cdn.example/eac3.m4s?deadline=100".to_string(), + ..Default::default() + }, + BilibiliDashAudioStream { + id: 30_251, + codecs: "fLaC".to_string(), + base_url: "https://cdn.example/flac.m4s?deadline=100".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + let (infos, default_mode) = bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &dash, + None, + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: None, + }, + )?; + + assert_eq!(default_mode, "dash"); + assert_eq!(infos.len(), 1); + let medias = &infos["dash"].medias; + assert_eq!(medias.len(), 1); + assert_eq!(medias[0].name, "DASH"); + assert_eq!(medias[0].format, "mpd"); + assert!(matches!( + &medias[0].provider, + PlaybackMediaProvider::Bilibili( + PlaybackBilibiliMedia::DirectDashManifest { mode_name, .. } + ) if mode_name == "dash" + )); + let PlaybackMetadata::Bilibili(metadata) = metadata else { + anyhow::bail!("expected Bilibili metadata"); + }; + let Some(manifest) = metadata.dash_manifest.as_ref() else { + anyhow::bail!("expected unified DASH manifest"); + }; + assert_eq!(manifest.video_streams.len(), 3); + assert_eq!(manifest.audio_streams.len(), 3); + Ok(()) + } - let duration_seconds = dash_resp - .as_ref() - .and_then(|resp| resp.dash.as_ref()) - .map(|dash| dash.duration); - if let Some(dash) = dash_resp.as_ref().and_then(|resp| resp.dash.as_ref()) { - if let Some(metadata) = metadata.as_bilibili_mut() { - metadata.min_buffer_time = Some(dash.min_buffer_time); - } - } + #[test] + fn unified_dash_merges_unique_video_and_audio_from_additional_response() -> TestResult { + let primary = BilibiliDashManifest { + video_streams: vec![BilibiliDashVideoStream { + id: 80, + codecid: 7, + codecs: "avc1.640032".to_string(), + base_url: "https://cdn.example/h264.m4s".to_string(), + ..Default::default() + }], + audio_streams: vec![BilibiliDashAudioStream { + id: 30_280, + codecs: "mp4a.40.2".to_string(), + base_url: "https://cdn.example/aac.m4s".to_string(), + ..Default::default() + }], + ..Default::default() + }; + let additional = BilibiliDashManifest { + video_streams: vec![BilibiliDashVideoStream { + id: 80, + codecid: 12, + codecs: "hev1.1.6.L120.90".to_string(), + base_url: "https://cdn.example/hevc.m4s".to_string(), + ..Default::default() + }], + audio_streams: vec![ + primary.audio_streams[0].clone(), + BilibiliDashAudioStream { + id: 30_251, + codecs: "fLaC".to_string(), + base_url: "https://cdn.example/flac.m4s".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); - let mut playback_infos = HashMap::new(); - let danmakus = vec![bilibili_vod_danmaku_track( - provider_instance_name, - &content_descriptor, - cid, - )]; + bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &primary, + Some(&additional), + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: None, + }, + )?; - let default_mode = if let Some(dash_resp) = dash_resp { - let dash = dash_resp.dash.as_ref().ok_or_else(|| { - ProviderError::ApiError( - "Bilibili PGC playback response did not include DASH streams" - .to_string(), - ) - })?; - let primary_dash = dash_manifest_from_upstream(dash); - let hevc_dash = dash_resp - .hevc_dash - .as_ref() - .filter(|dash| !dash.video_streams.is_empty()) - .map(dash_manifest_from_upstream); - let (dash_infos, default_mode) = bilibili_dash_playback_infos( - &mut metadata, - &content_descriptor, - &primary_dash, - hevc_dash.as_ref(), - BilibiliDashPlaybackOptions { - provider_instance_name, - subtitles: &subtitles, - danmakus: &danmakus, - client_profile: ctx.playback_client_profile(), - }, - )?; - playback_infos.extend(dash_infos); - default_mode - } else { - let request = bilibili_pgc_url_request(&sanitized_cookies, epid, cid, 80); - let pgc_resp = client.get_pgcurl(request).await?; - if let Some(metadata) = metadata.as_bilibili_mut() { - metadata.fallback_format = Some("durl".to_string()); - metadata.quality = Some(pgc_resp.current_quality); - } - let durl_swarm_id = super::provider_p2p_swarm_id( - Self::NAME, - provider_instance_name, - "media", - &format!( - "{content_descriptor}:durl:quality:{}", - pgc_resp.current_quality - ), - ); - playback_infos.insert( - "mp4".to_string(), - mode_info( - vec![bilibili_durl_media( - "MP4", - pgc_resp.segments.into_iter().map(|segment| { - (segment.url, segment.backup_urls, segment.duration_millis) - }), - durl_swarm_id, - )?], - subtitles, - danmakus, - ), - ); - "mp4".to_string() - }; + let PlaybackMetadata::Bilibili(metadata) = metadata else { + anyhow::bail!("expected Bilibili metadata"); + }; + let Some(manifest) = metadata.dash_manifest else { + anyhow::bail!("expected unified DASH manifest"); + }; + assert_eq!(manifest.video_streams.len(), 2); + assert_eq!(manifest.audio_streams.len(), 2); + assert_eq!(manifest.audio_streams[1].codecs, "fLaC"); + Ok(()) + } - Ok(PlaybackResult { - playback_infos, - default_mode, - provider: crate::models::SourceProvider::Bilibili, - provider_instance_name: provider_instance_name.map(str::to_string), - duration_seconds, - playback_kind: Some(crate::models::PlaybackKind::Regular), - metadata: Some(metadata), - }) - } + #[test] + fn dash_manifest_groups_each_codec_in_a_stable_adaptation_set() -> TestResult { + let video = |id: u64, codecs: &str| BilibiliDashVideoStream { + id, + codecid: u32::try_from(id).expect("test stream id fits u32"), + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/video-{id}.m4s"), + backup_urls: vec![format!("https://backup.example/video-{id}.m4s")], + ..Default::default() + }; + let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/audio-{id}.m4s"), + ..Default::default() + }; + let manifest = BilibiliDashManifest { + video_streams: vec![ + video(1, "avc1.640033"), + video(2, "hev1.1.6.L120.90"), + video(3, "av01.0.08M.08"), + video(4, "vp09.00.21.08"), + ], + audio_streams: vec![ + audio(30_280, "mp4a.40.2"), + audio(30_250, "ec-3"), + audio(30_251, "fLaC"), + ], + duration: 10.0, + min_buffer_time: 1.5, + }; - BilibiliSourceConfig::Live(config) => { - let room_id = config.room_id; + let mpd = build_bilibili_mpd_manifest(&manifest, |_index, url| url.to_string())?; - let request = bilibili_live_streams_request( - sanitized_cookies.clone(), - room_id, - bilibili_live_uses_hls(ctx.playback_client_profile()), - ); - let page_request = bilibili_parse_live_page_request(BilibiliParseLivePageRequest { - cookies: sanitized_cookies, - room_id, - }); - let (live_resp, live_page) = tokio::join!( - client.get_live_streams(request), - client.parse_live_page(page_request), - ); - let live_resp = live_resp?; - let live_started_at = match live_page { - Ok(page) => page.live_started_at, - Err(error) => { - tracing::warn!( - room_id, - error = %error, - "Failed to fetch Bilibili live start time" - ); - None - } - }; + for (id, label) in [ + (100, "H.264"), + (110, "HEVC"), + (120, "AV1"), + (130, "VP9"), + (200, "AAC"), + (210, "E-AC-3"), + (230, "FLAC"), + ] { + assert!(mpd.contains(&format!("{label}"))); + } + assert_eq!(mpd.matches("")); + assert!(mpd.contains("id=\"video-110-2-2\"")); + assert!(mpd.contains("id=\"audio-230-30251\"")); + Ok(()) + } - let metadata = PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { - room_id: Some(room_id), - live_started_at, - is_live: true, - is_currently_live: Some(true), - ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Live) - }); - // The versioned result is keyed by upstream content. Room and - // playlist identifiers belong to the live danmaku route, so - // they are attached after cache lookup for each request. - let playback_infos = bilibili_live_playback_infos(live_resp.live_streams, &[])?; + #[test] + fn dash_manifest_keeps_only_codecs_advertised_by_the_client() { + let video = |id: u64, codecs: &str| BilibiliDashVideoStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/video-{id}.m4s"), + ..Default::default() + }; + let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/audio-{id}.m4s"), + ..Default::default() + }; + let dash = BilibiliDashManifest { + video_streams: vec![ + video(32, "avc1.64001F"), + video(80, "avc1.640032"), + video(120, "avc1.640033"), + ], + audio_streams: vec![ + audio(30_280, "mp4a.40.2"), + audio(30_250, "ec-3"), + audio(30_251, "fLaC"), + ], + ..Default::default() + }; + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: PlaybackClientEnvironment::Web, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(crate::provider::PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Aac), + pipeline: PlaybackMediaPipeline::MediaSource, + codec_string: Some("avc1.64001F,mp4a.40.2".to_string()), + }], + ..PlaybackClientProfile::default() + }; - let default_mode = default_bilibili_live_mode(&playback_infos); + let filtered = filter_bilibili_dash_manifest(&dash, Some(&profile)); - Ok(PlaybackResult { - playback_infos, - default_mode, - provider: crate::models::SourceProvider::Bilibili, - provider_instance_name: provider_instance_name.map(str::to_string), - duration_seconds: None, - playback_kind: Some(crate::models::PlaybackKind::Live), - metadata: Some(metadata), - }) - } - } + assert_eq!(filtered.video_streams.len(), 1); + assert_eq!(filtered.video_streams[0].codecs, "avc1.64001F"); + assert_eq!(filtered.audio_streams.len(), 1); + assert_eq!(filtered.audio_streams[0].codecs, "mp4a.40.2"); } -} -fn bilibili_live_playback_infos( - mut live_streams: Vec, - danmakus: &[PlaybackDanmaku], -) -> Result, ProviderError> { - live_streams.sort_by(|left, right| { - right - .quality - .cmp(&left.quality) - .then_with(|| { - bilibili_live_format_rank(&left.format) - .cmp(&bilibili_live_format_rank(&right.format)) - }) - .then_with(|| { - bilibili_live_codec_rank(&left.codec).cmp(&bilibili_live_codec_rank(&right.codec)) - }) - }); - let mut route_hosts = Vec::new(); - for stream in &live_streams { - for url in &stream.urls { - if !route_hosts.contains(&url.host) { - route_hosts.push(url.host.clone()); - } - } - } + #[test] + fn dash_playback_metadata_contains_only_client_compatible_codecs() -> TestResult { + let video = |id: u64, codecs: &str| BilibiliDashVideoStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/video-{id}.m4s?deadline=100"), + ..Default::default() + }; + let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/audio-{id}.m4s?deadline=100"), + ..Default::default() + }; + let dash = BilibiliDashManifest { + video_streams: vec![ + video(32, "avc1.64001F"), + video(80, "avc1.640032"), + video(120, "av01.0.08M.08"), + ], + audio_streams: vec![audio(30_280, "mp4a.40.2"), audio(30_250, "ec-3")], + ..Default::default() + }; + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: PlaybackClientEnvironment::Web, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(crate::provider::PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Aac), + pipeline: PlaybackMediaPipeline::MediaSource, + codec_string: Some("avc1.64001F,mp4a.40.2".to_string()), + }], + ..PlaybackClientProfile::default() + }; + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); - let playback_infos = route_hosts - .iter() - .enumerate() - .filter_map(|(route_index, route_host)| { - let medias = live_streams + let (infos, default_mode) = bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &dash, + None, + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: Some(&profile), + }, + )?; + + assert_eq!(default_mode, "dash"); + assert_eq!(infos.len(), 1); + let PlaybackMetadata::Bilibili(metadata) = metadata else { + anyhow::bail!("expected Bilibili metadata"); + }; + let Some(manifest) = metadata.dash_manifest else { + anyhow::bail!("expected unified DASH manifest"); + }; + assert_eq!( + manifest + .video_streams .iter() - .flat_map(|stream| { - stream - .urls - .iter() - .filter(|&candidate| candidate.host == *route_host) - .map(|candidate| { - let mut media = playback_media( - stream.quality_name.clone(), - bilibili_live_media_format(stream).to_string(), - candidate.expires_at, - None, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::Direct { - url: candidate.url.clone(), - headers: bilibili_live_headers(), - }), - ); - media.metadata = Some(crate::models::PlaybackMediaMetadata { - resolution: None, - bitrate: None, - codec: (!stream.codec.is_empty()).then(|| stream.codec.clone()), - fps: None, - }); - media - }) - }) - .collect::>(); - if medias.is_empty() { - return None; - } - let mode_name = if route_index == 0 { - "main".to_string() - } else { - format!("backup_{route_index}") - }; - Some(( - mode_name, + .map(|stream| stream.codecs.as_str()) + .collect::>(), + vec!["avc1.64001F"] + ); + assert_eq!( + manifest + .audio_streams + .iter() + .map(|stream| stream.codecs.as_str()) + .collect::>(), + vec!["mp4a.40.2"] + ); + Ok(()) + } + + #[test] + fn dash_playback_reports_incompatible_video_and_audio_separately() { + let dash = BilibiliDashManifest { + video_streams: vec![BilibiliDashVideoStream { + id: 32, + codecs: "avc1.64001F".to_string(), + base_url: "https://cdn.example/video.m4s?deadline=100".to_string(), + ..Default::default() + }], + audio_streams: vec![BilibiliDashAudioStream { + id: 30_280, + codecs: "mp4a.40.2".to_string(), + base_url: "https://cdn.example/audio.m4s?deadline=100".to_string(), + ..Default::default() + }], + ..Default::default() + }; + let profile = |video_codec, audio_codec, codec_string: &str| PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: PlaybackClientEnvironment::Web, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(crate::provider::PlaybackContainer::Mp4), + video_codec: Some(video_codec), + audio_codec: Some(audio_codec), + pipeline: PlaybackMediaPipeline::MediaSource, + codec_string: Some(codec_string.to_string()), + }], + ..PlaybackClientProfile::default() + }; + + let unsupported_video = profile( + PlaybackVideoCodec::Av1, + PlaybackAudioCodec::Aac, + "av01.0.08M.08,mp4a.40.2", + ); + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + let video_error = bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &dash, + None, + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: Some(&unsupported_video), + }, + ) + .expect_err("the client does not advertise the upstream video codec"); + assert!(matches!( + video_error, + super::ProviderError::ClientIncompatible { + required_capability: Some(ref capability), + .. + } if capability == "dash_video_codec_string" + )); + + let unsupported_audio = profile( + PlaybackVideoCodec::H264, + PlaybackAudioCodec::Eac3, + "avc1.64001F,ec-3", + ); + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + let audio_error = bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &dash, + None, + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: Some(&unsupported_audio), + }, + ) + .expect_err("the client does not advertise the upstream audio codec"); + assert!(matches!( + audio_error, + super::ProviderError::ClientIncompatible { + required_capability: Some(ref capability), + .. + } if capability == "dash_audio_codec_string" + )); + } + + #[test] + fn unified_dash_keeps_one_proxy_route_and_a_content_level_swarm() { + let source = super::playback_media( + "DASH".to_string(), + "mpd".to_string(), + None, + Some("sm3_test_dash".to_string()), + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::Direct { + url: "https://cdn.example/source.mpd".to_string(), + headers: HashMap::new(), + }), + ); + let mut result = PlaybackResult { + playback_infos: HashMap::from([( + "dash".to_string(), PlaybackInfo { thumbnail: None, - medias, + medias: vec![source], default_media_index: Some(0), subtitles: Vec::new(), default_subtitle_index: None, - danmakus: danmakus.to_vec(), - default_danmaku_index: (!danmakus.is_empty()).then_some(0), + danmakus: Vec::new(), + default_danmaku_index: None, }, - )) - }) - .collect::>(); + )]), + default_mode: "dash".to_string(), + provider: crate::models::SourceProvider::Bilibili, + provider_instance_name: None, + duration_seconds: Some(10.0), + playback_kind: Some(crate::models::PlaybackKind::Regular), + metadata: Some(PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { + bvid: Some("BV1test".to_string()), + cid: Some(42), + dash_manifest: Some(BilibiliDashManifest { + video_streams: vec![ + BilibiliDashVideoStream { + id: 32, + codecs: "avc1.64001F".to_string(), + base_url: "https://cdn.example/dash.m4s?deadline=100".to_string(), + ..Default::default() + }, + BilibiliDashVideoStream { + id: 80, + codecs: "hev1.1.6.L120.90".to_string(), + base_url: "https://cdn.example/hevc.m4s?deadline=200".to_string(), + ..Default::default() + }, + ], + audio_streams: vec![BilibiliDashAudioStream { + id: 30_250, + codecs: "ec-3".to_string(), + base_url: "https://cdn.example/eac3.m4s?deadline=150".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video) + })), + }; + let mut eac3_result = result.clone(); - if playback_infos.is_empty() { - return Err(ProviderError::NotFound); + mark_bilibili_playback_resources( + &mut result, + "version", + 123, + crate::models::PlaybackProxyMode::Auto, + None, + ); + + assert_eq!(result.default_mode, "proxy_dash"); + assert!(!result.playback_infos.contains_key("dash")); + let proxied = &result.playback_infos["proxy_dash"].medias; + assert_eq!(proxied.len(), 1); + assert_eq!(proxied[0].name, "DASH"); + assert_eq!( + proxied[0].expire_at.map(|value| value.timestamp()), + Some(100) + ); + assert!(matches!( + &proxied[0].provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::ProxyDashManifest { + mode_name, + .. + }) if mode_name == "dash" + )); + assert_eq!( + proxied[0].p2p_swarm_id.as_deref(), + Some(bilibili_dash_manifest_swarm_id(None, "video:bvid:BV1test:cid:42").as_str()) + ); + + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(crate::provider::PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Eac3), + pipeline: PlaybackMediaPipeline::Native, + codec_string: Some("avc1.64001F,ec-3".to_string()), + }], + ..PlaybackClientProfile::default() + }; + let context = ProviderContext::new("test", ProviderActor::System) + .with_playback_client_profile(Some(profile)); + mark_bilibili_playback_resources( + &mut eac3_result, + "version", + 123, + crate::models::PlaybackProxyMode::Prefer, + Some(&context), + ); + + assert!(eac3_result.playback_infos.contains_key("dash")); + assert!(eac3_result.playback_infos.contains_key("proxy_dash")); } - Ok(playback_infos) -} + #[test] + fn live_streams_group_quality_variants_by_cdn_host() -> TestResult { + let stream = |quality, quality_name: &str, host: &str, suffix: &str, expires_at| { + super::bilibili_upstream::LiveStream { + quality, + quality_name: quality_name.to_string(), + protocol: "http_hls".to_string(), + format: "fmp4".to_string(), + codec: "avc".to_string(), + urls: vec![super::bilibili_upstream::LiveStreamUrl { + host: host.to_string(), + url: format!("{host}/{suffix}.m3u8"), + expires_at, + }], + } + }; + let infos = provider_ok(bilibili_live_playback_infos( + vec![ + stream(400, "1080P", "https://main.example", "high", Some(400)), + stream(80, "流畅", "https://main.example", "low", Some(80)), + stream(400, "1080P", "https://backup.example", "high", Some(401)), + stream(80, "流畅", "https://backup.example", "low", None), + ], + &[], + ))?; -fn bilibili_live_media_format(stream: &bilibili_upstream::LiveStream) -> &'static str { - if stream.protocol == "http_stream" { - "flv" - } else { - "m3u8" + assert_eq!(infos.len(), 2); + assert_eq!(infos["main"].medias.len(), 2); + assert_eq!(infos["main"].medias[0].name, "1080P"); + assert_eq!(infos["main"].medias[1].name, "流畅"); + assert_eq!( + infos["main"].medias[0] + .expire_at + .map(|value| value.timestamp()), + Some(400) + ); + assert_eq!( + infos["main"].medias[1] + .expire_at + .map(|value| value.timestamp()), + Some(80) + ); + assert_eq!(infos["backup_1"].medias.len(), 2); + assert_eq!( + infos["backup_1"].medias[0] + .expire_at + .map(|value| value.timestamp()), + Some(401) + ); + assert_eq!(infos["backup_1"].medias[1].expire_at, None); + Ok(()) } -} -const fn bilibili_live_format_rank(format: &str) -> u8 { - match format.as_bytes() { - b"ts" => 0, - b"fmp4" => 1, - _ => 2, + #[test] + fn live_streams_preserve_hls_and_flv_formats() -> TestResult { + let stream = + |protocol: &str, format: &str, url: &str| super::bilibili_upstream::LiveStream { + quality: 400, + quality_name: "1080P".to_string(), + protocol: protocol.to_string(), + format: format.to_string(), + codec: "avc".to_string(), + urls: vec![super::bilibili_upstream::LiveStreamUrl { + host: format!("https://{format}.example"), + url: url.to_string(), + expires_at: None, + }], + }; + let infos = provider_ok(bilibili_live_playback_infos( + vec![ + stream("http_hls", "fmp4", "https://fmp4.example/live.m3u8"), + stream("http_stream", "flv", "https://flv.example/live.flv"), + ], + &[], + ))?; + + let formats = infos + .values() + .flat_map(|info| info.medias.iter().map(|media| media.format.as_str())) + .collect::>(); + assert!(formats.contains(&"m3u8")); + assert!(formats.contains(&"flv")); + Ok(()) } -} -const fn bilibili_live_codec_rank(codec: &str) -> u8 { - match codec.as_bytes() { - b"avc" => 0, - b"hevc" => 1, - _ => 2, + #[test] + fn live_playback_rejects_an_empty_upstream_stream_set() { + assert!(matches!( + bilibili_live_playback_infos(Vec::new(), &[]), + Err(super::ProviderError::NotFound) + )); } -} -fn default_bilibili_live_mode(playback_infos: &HashMap) -> String { - playback_infos - .contains_key("main") - .then(|| "main".to_string()) - .or_else(|| playback_infos.keys().min().cloned()) - .unwrap_or_else(|| "main".to_string()) + #[test] + fn durl_manifest_rejects_line_breaks_in_segment_urls() { + let error = build_bilibili_durl_manifest(&[BilibiliDurlSegment { + url: "https://cdn.example/video.mp4".to_string(), + backup_urls: vec!["https://backup.example/video.mp4\n#EXT-X-ENDLIST".to_string()], + duration_millis: 1_000, + }]) + .expect_err("line breaks must not enter generated manifests"); + assert!(error.to_string().contains("line break")); + } } diff --git a/synctv-core/src/provider/traits.rs b/synctv-core/src/provider/traits.rs index 9723abe5..f76850ec 100644 --- a/synctv-core/src/provider/traits.rs +++ b/synctv-core/src/provider/traits.rs @@ -537,22 +537,6 @@ impl IntoIterator for DynamicListResult { } } -#[cfg(test)] -mod dynamic_pagination_tests { - use super::dynamic_page_has_more; - - #[test] - fn uses_the_upstream_page_size_before_filtering() { - assert!(dynamic_page_has_more(100, 1, 50, 50)); - assert!(!dynamic_page_has_more(100, 2, 50, 50)); - } - - #[test] - fn stops_on_the_last_short_upstream_page() { - assert!(!dynamic_page_has_more(75, 2, 50, 25)); - } -} - /// Media provider trait. /// /// Core interface that all providers must implement. The maintenance contract @@ -887,3 +871,19 @@ pub struct NextPlayItem { /// Provider-facing target payload for this playable item pub target: crate::models::ProviderTarget, } + +#[cfg(test)] +mod dynamic_pagination_tests { + use super::dynamic_page_has_more; + + #[test] + fn uses_the_upstream_page_size_before_filtering() { + assert!(dynamic_page_has_more(100, 1, 50, 50)); + assert!(!dynamic_page_has_more(100, 2, 50, 50)); + } + + #[test] + fn stops_on_the_last_short_upstream_page() { + assert!(!dynamic_page_has_more(75, 2, 50, 25)); + } +} diff --git a/synctv-core/src/repository/room_resource_event.rs b/synctv-core/src/repository/room_resource_event.rs index bbde668e..54592451 100644 --- a/synctv-core/src/repository/room_resource_event.rs +++ b/synctv-core/src/repository/room_resource_event.rs @@ -76,10 +76,10 @@ impl RoomResourceKind { } } -impl TryFrom<&str> for RoomResourceKind { - type Error = String; +impl core::str::FromStr for RoomResourceKind { + type Err = String; - fn try_from(value: &str) -> std::result::Result { + fn from_str(value: &str) -> std::result::Result { match value { "playback_state" => Ok(Self::PlaybackState), "playback" => Ok(Self::Playback), @@ -279,8 +279,7 @@ impl TryFrom for RoomResourceEventLog { Ok(Self { event_id: row.event_id, sequence: row.sequence, - resource_type: RoomResourceKind::try_from(row.resource_type.as_str()) - .map_err(Error::Internal)?, + resource_type: row.resource_type.parse().map_err(Error::Internal)?, event_type: row.event_type, payload: row.payload.map(|payload| payload.0), }) diff --git a/synctv-core/src/service/global_settings/types.rs b/synctv-core/src/service/global_settings/types.rs index 6853ec28..8a6ade69 100644 --- a/synctv-core/src/service/global_settings/types.rs +++ b/synctv-core/src/service/global_settings/types.rs @@ -188,92 +188,6 @@ impl std::str::FromStr for PermissionSet { } } -#[cfg(test)] -mod tests { - use super::{ - parse_rtmp_advertise_address, validate_webrtc_settings, IceServerList, PermissionSet, - WebRtcRuntimeSettings, - }; - use crate::models::RoomAdminPermissionBits; - use std::str::FromStr; - - #[test] - fn permission_set_accepts_exact_canonical_names() { - let permissions = PermissionSet::from_str( - r#"["manage_live_streams","view_members","view_playback_history"]"#, - ) - .expect("canonical permission names should parse"); - - assert_eq!( - permissions.bits().0, - RoomAdminPermissionBits::MANAGE_LIVE_STREAMS - | RoomAdminPermissionBits::VIEW_MEMBERS - | RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY - ); - } - - #[test] - fn permission_set_preserves_playback_history_in_display_round_trip() { - let original = PermissionSet::from_bits(crate::models::RoomPermissionSet( - RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY, - )); - let restored = PermissionSet::from_str(&original.to_string()) - .expect("displayed permission names should parse"); - - assert_eq!(restored, original); - } - - #[test] - fn voice_participant_limit_accepts_mesh_operating_range() { - for max_voice_participants_per_room in [2, 8, 32] { - assert!(validate_webrtc_settings(&WebRtcRuntimeSettings { - external_ice_servers: IceServerList::new(), - max_voice_participants_per_room, - }) - .is_ok()); - } - for max_voice_participants_per_room in [1, 33] { - assert!(validate_webrtc_settings(&WebRtcRuntimeSettings { - external_ice_servers: IceServerList::new(), - max_voice_participants_per_room, - }) - .is_err()); - } - } - - #[test] - fn rtmp_advertise_address_accepts_origin_without_business_path() { - let plain = parse_rtmp_advertise_address("rtmp://live.example.com") - .expect("RTMP origin should be accepted"); - assert_eq!(plain.scheme(), "rtmp"); - assert_eq!(plain.host_str(), Some("live.example.com")); - assert_eq!(plain.port(), None); - - let secure = parse_rtmp_advertise_address("rtmps://live.example.com:8443") - .expect("RTMPS origin with an explicit port should be accepted"); - assert_eq!(secure.scheme(), "rtmps"); - assert_eq!(secure.host_str(), Some("live.example.com")); - assert_eq!(secure.port(), Some(8443)); - } - - #[test] - fn rtmp_advertise_address_rejects_non_origin_components() { - for address in [ - "", - "http://live.example.com", - "rtmp://user:secret@live.example.com", - "rtmp://live.example.com/app", - "rtmp://live.example.com?token=secret", - "rtmp://live.example.com#fragment", - ] { - assert!( - parse_rtmp_advertise_address(address).is_err(), - "unexpectedly accepted {address}" - ); - } - } -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RoomPasswordPolicy { @@ -1612,3 +1526,89 @@ impl PublicSettings { } } } + +#[cfg(test)] +mod tests { + use super::{ + parse_rtmp_advertise_address, validate_webrtc_settings, IceServerList, PermissionSet, + WebRtcRuntimeSettings, + }; + use crate::models::RoomAdminPermissionBits; + use std::str::FromStr; + + #[test] + fn permission_set_accepts_exact_canonical_names() { + let permissions = PermissionSet::from_str( + r#"["manage_live_streams","view_members","view_playback_history"]"#, + ) + .expect("canonical permission names should parse"); + + assert_eq!( + permissions.bits().0, + RoomAdminPermissionBits::MANAGE_LIVE_STREAMS + | RoomAdminPermissionBits::VIEW_MEMBERS + | RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY + ); + } + + #[test] + fn permission_set_preserves_playback_history_in_display_round_trip() { + let original = PermissionSet::from_bits(crate::models::RoomPermissionSet( + RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY, + )); + let restored = PermissionSet::from_str(&original.to_string()) + .expect("displayed permission names should parse"); + + assert_eq!(restored, original); + } + + #[test] + fn voice_participant_limit_accepts_mesh_operating_range() { + for max_voice_participants_per_room in [2, 8, 32] { + assert!(validate_webrtc_settings(&WebRtcRuntimeSettings { + external_ice_servers: IceServerList::new(), + max_voice_participants_per_room, + }) + .is_ok()); + } + for max_voice_participants_per_room in [1, 33] { + assert!(validate_webrtc_settings(&WebRtcRuntimeSettings { + external_ice_servers: IceServerList::new(), + max_voice_participants_per_room, + }) + .is_err()); + } + } + + #[test] + fn rtmp_advertise_address_accepts_origin_without_business_path() { + let plain = parse_rtmp_advertise_address("rtmp://live.example.com") + .expect("RTMP origin should be accepted"); + assert_eq!(plain.scheme(), "rtmp"); + assert_eq!(plain.host_str(), Some("live.example.com")); + assert_eq!(plain.port(), None); + + let secure = parse_rtmp_advertise_address("rtmps://live.example.com:8443") + .expect("RTMPS origin with an explicit port should be accepted"); + assert_eq!(secure.scheme(), "rtmps"); + assert_eq!(secure.host_str(), Some("live.example.com")); + assert_eq!(secure.port(), Some(8443)); + } + + #[test] + fn rtmp_advertise_address_rejects_non_origin_components() { + for address in [ + "", + "http://live.example.com", + "rtmp://user:secret@live.example.com", + "rtmp://live.example.com/app", + "rtmp://live.example.com?token=secret", + "rtmp://live.example.com#fragment", + ] { + assert!( + parse_rtmp_advertise_address(address).is_err(), + "unexpectedly accepted {address}" + ); + } + } +} diff --git a/synctv-livestream/Cargo.toml b/synctv-livestream/Cargo.toml index 067a45b2..ba0d221b 100644 --- a/synctv-livestream/Cargo.toml +++ b/synctv-livestream/Cargo.toml @@ -108,7 +108,6 @@ jsonwebtoken.workspace = true # URL encoding percent-encoding.workspace = true -clap.workspace = true [lints] workspace = true diff --git a/synctv-management/Cargo.toml b/synctv-management/Cargo.toml index 0dd8ffd8..15c0dd79 100644 --- a/synctv-management/Cargo.toml +++ b/synctv-management/Cargo.toml @@ -58,7 +58,6 @@ synctv-adapter = { workspace = true, default-features = false } synctv-cluster = { workspace = true, default-features = false } synctv-common = { workspace = true, default-features = false } synctv-core = { workspace = true, default-features = false } -synctv-proxy = { workspace = true, default-features = false } synctv-proto = { workspace = true, default-features = false, features = ["main", "providers"] } synctv-realtime = { workspace = true, default-features = false } diff --git a/synctv-media-providers/Cargo.toml b/synctv-media-providers/Cargo.toml index 902ba6e4..476e88b9 100644 --- a/synctv-media-providers/Cargo.toml +++ b/synctv-media-providers/Cargo.toml @@ -26,7 +26,6 @@ tower.workspace = true tonic-prost.workspace = true tonic-health.workspace = true prost.workspace = true -prost-types.workspace = true hyper-util.workspace = true tokio-rustls.workspace = true rustls.workspace = true @@ -40,7 +39,6 @@ chrono.workspace = true html-escape.workspace = true # Utilities -anyhow.workspace = true thiserror.workspace = true bytes.workspace = true regex.workspace = true @@ -121,6 +119,7 @@ workspace = true tonic-prost-build.workspace = true [dev-dependencies] +anyhow.workspace = true tokio = { workspace = true, features = ["test-util"] } wiremock.workspace = true futures.workspace = true diff --git a/synctv-media-providers/src/acfun/types.rs b/synctv-media-providers/src/acfun/types.rs index 49b222cc..274b1ec4 100644 --- a/synctv-media-providers/src/acfun/types.rs +++ b/synctv-media-providers/src/acfun/types.rs @@ -170,34 +170,6 @@ fn parse_display_count(value: &str) -> Option { Some(value.round() as u64) } -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Deserialize)] - struct OptionalCount { - #[serde(default, deserialize_with = "deserialize_optional_u64")] - value: Option, - } - - fn count(value: &serde_json::Value) -> Option { - serde_json::from_value::(serde_json::json!({ "value": value })) - .expect("optional display counts should deserialize") - .value - } - - #[test] - fn optional_display_counts_accept_numbers_and_ignore_labels() { - assert_eq!(count(&serde_json::json!(483)), Some(483)); - assert_eq!(count(&serde_json::json!("1,234")), Some(1_234)); - assert_eq!(count(&serde_json::json!("1.2万")), Some(12_000)); - assert_eq!(count(&serde_json::json!("3亿")), Some(300_000_000)); - assert_eq!(count(&serde_json::json!("-1万")), None); - assert_eq!(count(&serde_json::json!("点赞")), None); - assert_eq!(count(&serde_json::json!("-")), None); - } -} - #[derive(Debug, Default, Deserialize)] pub(crate) struct PageUser { #[serde(default)] @@ -379,3 +351,31 @@ pub(crate) struct RawDanmaku { pub mode: Option, pub size: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize)] + struct OptionalCount { + #[serde(default, deserialize_with = "deserialize_optional_u64")] + value: Option, + } + + fn count(value: &serde_json::Value) -> Option { + serde_json::from_value::(serde_json::json!({ "value": value })) + .expect("optional display counts should deserialize") + .value + } + + #[test] + fn optional_display_counts_accept_numbers_and_ignore_labels() { + assert_eq!(count(&serde_json::json!(483)), Some(483)); + assert_eq!(count(&serde_json::json!("1,234")), Some(1_234)); + assert_eq!(count(&serde_json::json!("1.2万")), Some(12_000)); + assert_eq!(count(&serde_json::json!("3亿")), Some(300_000_000)); + assert_eq!(count(&serde_json::json!("-1万")), None); + assert_eq!(count(&serde_json::json!("点赞")), None); + assert_eq!(count(&serde_json::json!("-")), None); + } +} diff --git a/synctv-media-providers/src/fnos/media_types.rs b/synctv-media-providers/src/fnos/media_types.rs index cc1dd04d..649b38c6 100644 --- a/synctv-media-providers/src/fnos/media_types.rs +++ b/synctv-media-providers/src/fnos/media_types.rs @@ -120,48 +120,6 @@ impl FnosMediaItem { } } -#[cfg(test)] -mod tests { - use super::*; - - fn item(item_type: &str, title: &str, season_number: u32) -> FnosMediaItem { - FnosMediaItem { - guid: "item".to_string(), - title: title.to_string(), - item_type: item_type.to_string(), - poster: None, - tv_title: Some("Series".to_string()), - parent_title: None, - parent_guid: None, - ancestor_guid: None, - ancestor_name: None, - ancestor_category: None, - watched: 0, - is_favorite: 0, - ts: 0, - duration: 0, - episode_number: 0, - season_number, - vote_average: None, - overview: None, - media_guid: None, - video_guid: None, - audio_guid: None, - subtitle_guid: None, - single_child_guid: None, - } - } - - #[test] - fn uses_native_episode_title_and_numbered_season_title() { - assert_eq!( - item("Episode", "First Tide", 1).display_title(), - "First Tide" - ); - assert_eq!(item("Season", "", 2).display_title(), "S02"); - } -} - #[derive(Debug, Clone, Serialize)] pub struct FnosPlayInfoRequest { pub item_guid: String, @@ -371,3 +329,45 @@ pub struct FnosMediaCommandRequest { pub(crate) struct FnosItemGuidRequest<'a> { pub guid: &'a str, } + +#[cfg(test)] +mod tests { + use super::*; + + fn item(item_type: &str, title: &str, season_number: u32) -> FnosMediaItem { + FnosMediaItem { + guid: "item".to_string(), + title: title.to_string(), + item_type: item_type.to_string(), + poster: None, + tv_title: Some("Series".to_string()), + parent_title: None, + parent_guid: None, + ancestor_guid: None, + ancestor_name: None, + ancestor_category: None, + watched: 0, + is_favorite: 0, + ts: 0, + duration: 0, + episode_number: 0, + season_number, + vote_average: None, + overview: None, + media_guid: None, + video_guid: None, + audio_guid: None, + subtitle_guid: None, + single_child_guid: None, + } + } + + #[test] + fn uses_native_episode_title_and_numbered_season_title() { + assert_eq!( + item("Episode", "First Tide", 1).display_title(), + "First Tide" + ); + assert_eq!(item("Season", "", 2).display_title(), "S02"); + } +} diff --git a/synctv-proxy/Cargo.toml b/synctv-proxy/Cargo.toml index 703e4994..0fa3555b 100644 --- a/synctv-proxy/Cargo.toml +++ b/synctv-proxy/Cargo.toml @@ -44,7 +44,6 @@ async-trait.workspace = true bincode.workspace = true serde.workspace = true quick-xml.workspace = true -tempfile.workspace = true tokio-util.workspace = true rayon.workspace = true tonic.workspace = true @@ -56,6 +55,7 @@ subtle.workspace = true tonic-prost-build.workspace = true [dev-dependencies] +tempfile.workspace = true wiremock.workspace = true tokio = { workspace = true, features = ["test-util", "macros", "rt-multi-thread"] } reqwest.workspace = true diff --git a/synctv-xiu/src/streamhub/define.rs b/synctv-xiu/src/streamhub/define.rs index 7ed1bfe2..72cfa55e 100644 --- a/synctv-xiu/src/streamhub/define.rs +++ b/synctv-xiu/src/streamhub/define.rs @@ -389,42 +389,6 @@ pub const STREAM_HUB_EVENT_CHANNEL_CAPACITY: usize = 4096; pub type BroadcastEventSender = broadcast::Sender; pub type BroadcastEventReceiver = broadcast::Receiver; -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn budgeted_frame_channel_releases_bytes_after_receive() { - let (sender, mut receiver) = FrameDataSender::budgeted(4096, 8); - let first = FrameData::Video { - timestamp: 0, - data: Bytes::from_static(b"123456"), - }; - sender - .try_send(first) - .expect("first frame should fit budget"); - - let second = FrameData::Audio { - timestamp: 1, - data: Bytes::from_static(b"123"), - }; - assert!(matches!( - sender.try_send(second), - Err(FrameTrySendError::Full(_)) - )); - - let received = receiver.recv().await.expect("first frame should arrive"); - assert_eq!(frame_data_bytes(&received), 6); - - sender - .try_send(FrameData::Audio { - timestamp: 2, - data: Bytes::from_static(b"123"), - }) - .expect("released permits should be reusable"); - } -} - /// Called when media enters a locally owned publisher transceiver. /// /// The callback runs before subscriber fan-out, so publisher liveness remains @@ -656,3 +620,39 @@ pub enum StatisticData { start_time: DateTime, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn budgeted_frame_channel_releases_bytes_after_receive() { + let (sender, mut receiver) = FrameDataSender::budgeted(4096, 8); + let first = FrameData::Video { + timestamp: 0, + data: Bytes::from_static(b"123456"), + }; + sender + .try_send(first) + .expect("first frame should fit budget"); + + let second = FrameData::Audio { + timestamp: 1, + data: Bytes::from_static(b"123"), + }; + assert!(matches!( + sender.try_send(second), + Err(FrameTrySendError::Full(_)) + )); + + let received = receiver.recv().await.expect("first frame should arrive"); + assert_eq!(frame_data_bytes(&received), 6); + + sender + .try_send(FrameData::Audio { + timestamp: 2, + data: Bytes::from_static(b"123"), + }) + .expect("released permits should be reusable"); + } +}