diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a93a3d6..caa2722a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: test on: push: - branches: [master, dev] + branches: [dev, master, new-api] paths: [ "**.rs", @@ -37,26 +37,17 @@ on: env: RUST_BACKTRACE: 1 + RUSTFLAGS: -D warnings + +# The layering here is as follows, checking in priority from highest to lowest: +# 1. absence of errors and warnings on Linux/x86 +# 2. cross compilation on Windows and Linux/ARM +# 3. absence of lints +# 4. code formatting jobs: - fmt: - name: rustfmt - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - components: rustfmt - - run: cargo fmt --all -- --check - test-linux: - needs: fmt - name: cargo +${{ matrix.toolchain }} build (${{ matrix.os }}) + name: cargo +${{ matrix.toolchain }} check (${{ matrix.os }}) runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.experimental }} strategy: @@ -64,14 +55,13 @@ jobs: matrix: os: [ubuntu-latest] toolchain: - - 1.57 # MSRV (Minimum supported rust version) + - "1.61" # MSRV (Minimum supported rust version) - stable - - beta experimental: [false] - # Ignore failures in nightly + # Ignore failures in beta include: - os: ubuntu-latest - toolchain: nightly + toolchain: beta experimental: true steps: - name: Checkout code @@ -106,22 +96,25 @@ jobs: - run: cargo test --workspace - run: cargo install cargo-hack - - run: cargo hack --workspace --remove-dev-deps - - run: cargo build -p librespot-core --no-default-features - - run: cargo build -p librespot-core - - run: cargo hack build --each-feature -p librespot-discovery - - run: cargo hack build --each-feature -p librespot-playback - - run: cargo hack build --each-feature + - run: cargo hack --workspace --remove-dev-deps + - run: cargo check -p librespot-core --no-default-features + - run: cargo check -p librespot-core + - run: cargo hack check --each-feature -p librespot-discovery + - run: cargo hack check --each-feature -p librespot-playback + - run: cargo hack check --each-feature test-windows: - needs: fmt - name: cargo build (${{ matrix.os }}) + needs: test-linux + name: cargo +${{ matrix.toolchain }} check (${{ matrix.os }}) runs-on: ${{ matrix.os }} + continue-on-error: false strategy: fail-fast: false matrix: os: [windows-latest] - toolchain: [stable] + toolchain: + - "1.61" # MSRV (Minimum supported rust version) + - stable steps: - name: Checkout code uses: actions/checkout@v2 @@ -153,20 +146,22 @@ jobs: - run: cargo install cargo-hack - run: cargo hack --workspace --remove-dev-deps - - run: cargo build --no-default-features - - run: cargo build + - run: cargo check --no-default-features + - run: cargo check test-cross-arm: - needs: fmt + name: cross +${{ matrix.toolchain }} build ${{ matrix.target }} + needs: test-linux runs-on: ${{ matrix.os }} continue-on-error: false strategy: fail-fast: false matrix: - include: - - os: ubuntu-latest - target: armv7-unknown-linux-gnueabihf - toolchain: stable + os: [ubuntu-latest] + target: [armv7-unknown-linux-gnueabihf] + toolchain: + - "1.61" # MSRV (Minimum supported rust version) + - stable steps: - name: Checkout code uses: actions/checkout@v2 @@ -197,3 +192,67 @@ jobs: run: cargo install cross || true - name: Build run: cross build --target ${{ matrix.target }} --no-default-features + + clippy: + needs: [test-cross-arm, test-windows] + name: cargo +${{ matrix.toolchain }} clippy (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + continue-on-error: false + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + toolchain: [stable] + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.toolchain }} + override: true + components: clippy + + - name: Get Rustc version + id: get-rustc-version + run: echo "::set-output name=version::$(rustc -V)" + shell: bash + + - name: Cache Rust dependencies + uses: actions/cache@v2 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git + target + key: ${{ runner.os }}-${{ steps.get-rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock') }} + + - name: Install developer package dependencies + run: sudo apt-get update && sudo apt-get install libpulse-dev portaudio19-dev libasound2-dev libsdl2-dev gstreamer1.0-dev libgstreamer-plugins-base1.0-dev libavahi-compat-libdnssd-dev + + - run: cargo install cargo-hack + - run: cargo hack --workspace --remove-dev-deps + - run: cargo clippy -p librespot-core --no-default-features + - run: cargo clippy -p librespot-core + - run: cargo hack clippy --each-feature -p librespot-discovery + - run: cargo hack clippy --each-feature -p librespot-playback + - run: cargo hack clippy --each-feature + + fmt: + needs: clippy + name: cargo +${{ matrix.toolchain }} fmt + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Install toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + components: rustfmt + - run: cargo fmt --all -- --check diff --git a/.gitignore b/.gitignore index 1fa44327..eebf401d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,4 @@ spotify_appkey.key .project .history *.save - - +*.*~ diff --git a/COMPILING.md b/COMPILING.md index 9163afa3..527be464 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -7,7 +7,7 @@ In order to compile librespot, you will first need to set up a suitable Rust bui ### Install Rust The easiest, and recommended way to get Rust is to use [rustup](https://rustup.rs). Once that’s installed, Rust's standard tools should be set up and ready to use. -*Note: The current minimum required Rust version at the time of writing is 1.57, you can find the current minimum version specified in the `.github/workflow/test.yml` file.* +*Note: The current minimum supported Rust version at the time of writing is 1.61.* #### Additional Rust tools - `rustfmt` To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust-lang/rustfmt) and [`clippy`](https://github.com/rust-lang/rust-clippy), which are installed by default with `rustup` these days, else they can be installed manually with: @@ -15,11 +15,11 @@ To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust- rustup component add rustfmt rustup component add clippy ``` -Using `rustfmt` is not optional, as our CI checks against this repo's rules. +Using `cargo fmt` and `cargo clippy` is not optional, as our CI checks against this repo's rules. ### General dependencies -Along with Rust, you will also require a C compiler. - +Along with Rust, you will also require a C compiler. + On Debian/Ubuntu, install with: ```shell sudo apt-get install build-essential @@ -27,10 +27,10 @@ sudo apt-get install build-essential ``` On Fedora systems, install with: ```shell -sudo dnf install gcc +sudo dnf install gcc ``` ### Audio library dependencies -Depending on the chosen backend, specific development libraries are required. +Depending on the chosen backend, specific development libraries are required. *_Note this is an non-exhaustive list, open a PR to add to it!_* @@ -63,7 +63,7 @@ sudo dnf install alsa-lib-devel The recommended method is to first fork the repo, so that you have a copy that you have read/write access to. After that, it’s a simple case of cloning your fork. ```bash -git clone git@github.com:YOURUSERNAME/librespot.git +git clone git@github.com:YOUR_USERNAME/librespot.git ``` ## Compiling & Running diff --git a/Cargo.lock b/Cargo.lock index 82a555dd..299ee713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,45 +19,13 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "aes" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561" +checksum = "bfe0133578c0986e1fe3dfcd4af1cc5b2dd6c3dbf534d69916ce16a2701d40ba" dependencies = [ - "aes-soft", - "aesni", + "cfg-if", "cipher", -] - -[[package]] -name = "aes-ctr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7729c3cde54d67063be556aeac75a81330d802f0259500ca40cb52967f975763" -dependencies = [ - "aes-soft", - "aesni", - "cipher", - "ctr", -] - -[[package]] -name = "aes-soft" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072" -dependencies = [ - "cipher", - "opaque-debug", -] - -[[package]] -name = "aesni" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce" -dependencies = [ - "cipher", - "opaque-debug", + "cpufeatures", ] [[package]] @@ -103,6 +71,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfb6d71005dc22a708c7496eee5c8dc0300ee47355de6256c3b35b12b5fef596" +[[package]] +name = "arrayvec" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" + [[package]] name = "async-trait" version = "0.1.56" @@ -152,6 +126,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "base64ct" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bdca834647821e0b13d9539a8634eb62d3501b6b6c2cec1722786ee6671b851" + [[package]] name = "bindgen" version = "0.59.2" @@ -177,15 +157,6 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.10.2" @@ -201,6 +172,12 @@ version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" +[[package]] +name = "bytemuck" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5377c8865e74a160d21f29c2d40669f53286db6eab59b88540cbb12ffc8b835" + [[package]] name = "byteorder" version = "1.4.3" @@ -252,26 +229,14 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "chrono" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" -dependencies = [ - "libc", - "num-integer", - "num-traits", - "time 0.1.44", - "winapi", -] - [[package]] name = "cipher" -version = "0.2.5" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e" dependencies = [ - "generic-array", + "crypto-common", + "inout", ] [[package]] @@ -285,17 +250,6 @@ dependencies = [ "libloading 0.7.3", ] -[[package]] -name = "colored" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" -dependencies = [ - "atty", - "lazy_static", - "winapi", -] - [[package]] name = "combine" version = "4.6.4" @@ -306,6 +260,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3" + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.3" @@ -366,6 +336,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c6a1d5fa1de37e071642dfa44ec552ca5b299adb128fab16138e24b548fd21" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -377,20 +357,19 @@ dependencies = [ ] [[package]] -name = "crypto-mac" -version = "0.11.1" +name = "ct-logs" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" +checksum = "c1a816186fa68d9e426e3cb4ae4dff1fcd8e4a2c34b781bf7a822574a0d0aac8" dependencies = [ - "generic-array", - "subtle", + "sct 0.6.1", ] [[package]] name = "ctr" -version = "0.6.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f" +checksum = "0d14f329cfbaf5d0e06b5e87fff7e265d2673c5ea7d2c27691a2c107db1442a0" dependencies = [ "cipher", ] @@ -431,12 +410,14 @@ dependencies = [ ] [[package]] -name = "digest" -version = "0.9.0" +name = "der" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +checksum = "6919815d73839e7ad218de758883aae3a257ba6759ce7a9992501efbb53d705c" dependencies = [ - "generic-array", + "const-oid", + "crypto-bigint", + "pem-rfc7468", ] [[package]] @@ -445,8 +426,9 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" dependencies = [ - "block-buffer 0.10.2", + "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -459,6 +441,35 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-iterator" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a0ac4aeb3a18f92eaf09c6bb9b3ac30ff61ca95514fc58cbead1c9a6bf5401" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13f1e69590421890f90448c3cd5f554746a31adc6dc0dac406ec6901db8dc25" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_logger" version = "0.9.0" @@ -619,7 +630,19 @@ checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", +] + +[[package]] +name = "getset" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45727250e75cc04ff2846a66397da8ef2b3db8e40e0cef4df67950a07621eb9" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -628,6 +651,19 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22030e2c5a68ec659fde1e949a745124b48e6fa8b045b7ed5bd1fe4ccc5c4e5d" +[[package]] +name = "git2" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0155506aab710a86160ddb504a480d2964d7ab5b9e62419be69e0032bc5931c" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "glib" version = "0.15.12" @@ -814,6 +850,25 @@ dependencies = [ "system-deps", ] +[[package]] +name = "h2" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a82c6d637fc9515a4694bbf1cb2457b79d81ce52b3108bdeea58b07dd34a57" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -833,7 +888,7 @@ dependencies = [ "http", "httpdate", "mime", - "sha-1 0.10.0", + "sha-1", ] [[package]] @@ -868,12 +923,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hmac" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "crypto-mac", - "digest 0.9.0", + "digest", ] [[package]] @@ -937,6 +991,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "httparse", @@ -961,8 +1016,44 @@ dependencies = [ "headers", "http", "hyper", + "hyper-rustls 0.22.1", + "rustls-native-certs 0.5.0", "tokio", + "tokio-rustls 0.22.0", "tower-service", + "webpki 0.21.4", +] + +[[package]] +name = "hyper-rustls" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f9f7a97316d44c0af9b0301e65010573a853a9fc97046d7331d7f6bc0fd5a64" +dependencies = [ + "ct-logs", + "futures-util", + "hyper", + "log", + "rustls 0.19.1", + "rustls-native-certs 0.5.0", + "tokio", + "tokio-rustls 0.22.0", + "webpki 0.21.4", +] + +[[package]] +name = "hyper-rustls" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" +dependencies = [ + "http", + "hyper", + "log", + "rustls 0.20.6", + "rustls-native-certs 0.6.2", + "tokio", + "tokio-rustls 0.23.4", ] [[package]] @@ -1002,6 +1093,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" @@ -1110,6 +1210,9 @@ name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin", +] [[package]] name = "lazycell" @@ -1117,23 +1220,24 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" -[[package]] -name = "lewton" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" -dependencies = [ - "byteorder", - "ogg", - "tinyvec", -] - [[package]] name = "libc" version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" +[[package]] +name = "libgit2-sys" +version = "0.13.4+1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0fa6563431ede25f5cc7f6d803c6afbc1c5d3ad3d4925d12c882bf2b526f5d1" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.6.7" @@ -1230,12 +1334,10 @@ dependencies = [ name = "librespot" version = "0.4.2" dependencies = [ - "base64", "env_logger", "futures-util", "getopts", "hex", - "hyper", "librespot-audio", "librespot-connect", "librespot-core", @@ -1245,7 +1347,7 @@ dependencies = [ "librespot-protocol", "log", "rpassword", - "sha-1 0.9.8", + "sha1", "thiserror", "tokio", "url", @@ -1255,13 +1357,18 @@ dependencies = [ name = "librespot-audio" version = "0.4.2" dependencies = [ - "aes-ctr", + "aes", "byteorder", "bytes", + "ctr", + "futures-core", "futures-util", + "hyper", "librespot-core", "log", + "parking_lot 0.12.1", "tempfile", + "thiserror", "tokio", ] @@ -1272,7 +1379,6 @@ dependencies = [ "form_urlencoded", "futures-util", "librespot-core", - "librespot-discovery", "librespot-playback", "librespot-protocol", "log", @@ -1280,6 +1386,7 @@ dependencies = [ "rand", "serde", "serde_json", + "thiserror", "tokio", "tokio-stream", ] @@ -1292,6 +1399,7 @@ dependencies = [ "base64", "byteorder", "bytes", + "dns-sd", "env_logger", "form_urlencoded", "futures-core", @@ -1301,23 +1409,31 @@ dependencies = [ "httparse", "hyper", "hyper-proxy", + "hyper-rustls 0.23.0", "librespot-protocol", "log", + "num", "num-bigint", + "num-derive", "num-integer", "num-traits", "once_cell", + "parking_lot 0.12.1", "pbkdf2", "priority-queue", "protobuf", + "quick-xml", "rand", + "rsa", "serde", "serde_json", - "sha-1 0.9.8", + "sha1", "shannon", "thiserror", + "time", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "url", "uuid", @@ -1328,12 +1444,15 @@ dependencies = [ name = "librespot-discovery" version = "0.4.2" dependencies = [ - "aes-ctr", + "aes", "base64", + "cfg-if", + "ctr", "dns-sd", "form_urlencoded", "futures", "futures-core", + "futures-util", "hex", "hmac", "hyper", @@ -1342,8 +1461,7 @@ dependencies = [ "log", "rand", "serde_json", - "sha-1 0.9.8", - "simple_logger", + "sha1", "thiserror", "tokio", ] @@ -1354,10 +1472,13 @@ version = "0.4.2" dependencies = [ "async-trait", "byteorder", + "bytes", "librespot-core", "librespot-protocol", "log", "protobuf", + "thiserror", + "uuid", ] [[package]] @@ -1367,14 +1488,12 @@ dependencies = [ "alsa", "byteorder", "cpal", - "futures-executor", "futures-util", "glib", "gstreamer", "gstreamer-app", "gstreamer-audio", "jack 0.10.0", - "lewton", "libpulse-binding", "libpulse-simple-binding", "librespot-audio", @@ -1389,6 +1508,7 @@ dependencies = [ "rodio", "sdl2", "shell-words", + "symphonia", "thiserror", "tokio", "zerocopy", @@ -1403,6 +1523,18 @@ dependencies = [ "protobuf-codegen-pure", ] +[[package]] +name = "libz-sys" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lock_api" version = "0.4.7" @@ -1487,7 +1619,7 @@ checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys", ] @@ -1585,6 +1717,20 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43db66d1170d347f9a065114077f7dccb00c1b9478c89384490a3425279a4606" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.3" @@ -1597,6 +1743,32 @@ dependencies = [ "rand", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "566d173b2f9406afbc5510a90925d5a2cd80cae4605631f1212303df265de011" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ae39348c8bc5fbd7f40c727a9925f03517afd2ab27d46702108b6a7e5414c19" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.3.3" @@ -1618,6 +1790,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.1" @@ -1625,6 +1808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" dependencies = [ "autocfg", + "num-bigint", "num-integer", "num-traits", ] @@ -1727,10 +1911,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" [[package]] -name = "opaque-debug" -version = "0.3.0" +name = "openssl-probe" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "option-operations" @@ -1800,11 +1984,11 @@ checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" [[package]] name = "pbkdf2" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95f5254224e617595d2cc3cc73ff0a5eaf2637519e25f03388154e9378b6ffa" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "crypto-mac", + "digest", "hmac", ] @@ -1814,6 +1998,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "pem-rfc7468" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01de5d978f34aa4b2296576379fcc416034702fd94117c56ffd8a1a767cefb30" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -1842,6 +2035,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78f66c04ccc83dd4486fd46c33896f4e17b24a7a3a6400dedc48ed0ddd72320" +dependencies = [ + "der", + "pkcs8", + "zeroize", +] + +[[package]] +name = "pkcs8" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cabda3fb821068a9a4fab19a683eac3af12edf0f34b94a8be53c4972b8149d0" +dependencies = [ + "der", + "spki", + "zeroize", +] + [[package]] name = "pkg-config" version = "0.3.25" @@ -1959,6 +2174,16 @@ dependencies = [ "protobuf-codegen", ] +[[package]] +name = "quick-xml" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9279fbdacaad3baf559d8cabe0acc3d06e30ea14931af31af79578ac0946decc" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.20" @@ -2043,6 +2268,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + [[package]] name = "rodio" version = "0.15.0" @@ -2054,16 +2294,34 @@ dependencies = [ [[package]] name = "rpassword" -version = "6.0.1" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf099a1888612545b683d2661a1940089f6c2e5a8e38979b2159da876bfd956" +checksum = "26b763cb66df1c928432cc35053f8bd4cec3335d8559fc16010017d16b3c1680" dependencies = [ "libc", - "serde", - "serde_json", "winapi", ] +[[package]] +name = "rsa" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf22754c49613d2b3b119f0e5d46e34a2c628a937e3024b8762de4e7d8c710b" +dependencies = [ + "byteorder", + "digest", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "smallvec", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.21" @@ -2077,14 +2335,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] -name = "rustc_version" -version = "0.4.0" +name = "rustls" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" dependencies = [ - "semver", + "base64", + "log", + "ring", + "sct 0.6.1", + "webpki 0.21.4", ] +[[package]] +name = "rustls" +version = "0.20.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" +dependencies = [ + "log", + "ring", + "sct 0.7.0", + "webpki 0.22.0", +] + +[[package]] +name = "rustls-native-certs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07b7c1885bd8ed3831c289b7870b13ef46fe0e856d288c30d9cc17d75a2092" +dependencies = [ + "openssl-probe", + "rustls 0.19.1", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7522c9de787ff061458fe9a829dc790a3f5b22dc571694fc5883f448b94d9a9" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c8ad4f0c00e1eb5bc7614d236a7f1300e3dbd76b68cac8e06fb00b015ad8d8" + [[package]] name = "ryu" version = "1.0.10" @@ -2100,12 +2413,42 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" +dependencies = [ + "lazy_static", + "windows-sys", +] + [[package]] name = "scopeguard" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sdl2" version = "0.35.2" @@ -2130,10 +2473,27 @@ dependencies = [ ] [[package]] -name = "semver" -version = "1.0.12" +name = "security-framework" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2333e6df6d6598f2b1974829f853c2b4c5f4a6e503c10af918081aa6f8564e1" +checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] name = "serde" @@ -2166,19 +2526,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha-1" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha-1" version = "0.10.0" @@ -2187,7 +2534,18 @@ checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.3", + "digest", +] + +[[package]] +name = "sha1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77f4e7f65455545c2153c1253d25056825e77ee2533f0e41deb65a93a34852f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -2220,19 +2578,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simple_logger" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "166fea527c36d9b8a0a88c0c6d4c5077c699d9ffb5cf890b231a3f08c35f3d40" -dependencies = [ - "atty", - "colored", - "log", - "time 0.3.11", - "winapi", -] - [[package]] name = "slab" version = "0.4.7" @@ -2258,6 +2603,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spki" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d01ac02a6ccf3e07db148d2be087da624fea0221a16152ed01f0496a6b0a27" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stdweb" version = "0.1.3" @@ -2276,6 +2637,91 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +[[package]] +name = "symphonia" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17033fe05e4f7f10a6ad602c272bafd2520b2e5cdd9feb61494d9cdce08e002f" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5d3d53535ae2b7d0e39e82f683cac5398a6c8baca25ff1183e107d13959d3e" +dependencies = [ + "bitflags", + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323b94435a1a807e1001e29490aeaef2660fb72b145d47497e8429a6cb1d67c3" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199a6417cd4115bac79289b64b859358ea050b7add0ceb364dc991f628c5b347" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2f741469a0f103607ed1f2605f7f00b13ba044ea9ddc616764558c6d3d9b7d" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed71acf6b5e6e8bee1509597b86365a06b78c1d73218df47357620a6fe5997b" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cbb0766ce77a8aef535f9438db645e7b6f1b2c4cf3be9bf246b4e11a7d5531" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.98" @@ -2366,17 +2812,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "time" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - [[package]] name = "time" version = "0.3.11" @@ -2386,15 +2821,8 @@ dependencies = [ "itoa", "libc", "num_threads", - "time-macros", ] -[[package]] -name = "time-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" - [[package]] name = "tinyvec" version = "1.6.0" @@ -2442,6 +2870,28 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +dependencies = [ + "rustls 0.19.1", + "tokio", + "webpki 0.21.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls 0.20.6", + "tokio", + "webpki 0.22.0", +] + [[package]] name = "tokio-stream" version = "0.1.9" @@ -2453,6 +2903,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181" +dependencies = [ + "futures-util", + "log", + "rustls 0.20.6", + "rustls-native-certs 0.6.2", + "tokio", + "tokio-rustls 0.23.4", + "tungstenite", + "webpki 0.22.0", +] + [[package]] name = "tokio-util" version = "0.7.3" @@ -2484,9 +2950,9 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" +checksum = "2fce9567bd60a67d08a16488756721ba392f24f29006402881e43b19aac64307" dependencies = [ "cfg-if", "pin-project-lite", @@ -2508,6 +2974,27 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "tungstenite" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0" +dependencies = [ + "base64", + "byteorder", + "bytes", + "http", + "httparse", + "log", + "rand", + "rustls 0.20.6", + "sha-1", + "thiserror", + "url", + "utf-8", + "webpki 0.22.0", +] + [[package]] name = "typenum" version = "1.15.0" @@ -2547,6 +3034,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "url" version = "2.2.2" @@ -2559,6 +3052,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "uuid" version = "1.1.2" @@ -2569,14 +3068,25 @@ dependencies = [ ] [[package]] -name = "vergen" -version = "3.2.0" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7141e445af09c8919f1d5f8a20dae0b20c3b57a45dee0d5823c6ed5d237f15a" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "7.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bbc4fafd30514504c7593cfa52eaf4d6c4ef660386e2ec54edc17f14aa08e8d" dependencies = [ - "bitflags", - "chrono", - "rustc_version", + "anyhow", + "cfg-if", + "enum-iterator", + "getset", + "git2", + "rustversion", + "thiserror", + "time", ] [[package]] @@ -2612,12 +3122,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2688,6 +3192,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2782,3 +3306,9 @@ dependencies = [ "syn", "synstructure", ] + +[[package]] +name = "zeroize" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" diff --git a/Cargo.toml b/Cargo.toml index c7e9ef37..2c3f2769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,18 +49,16 @@ path = "protocol" version = "0.4.2" [dependencies] -base64 = "0.13" -env_logger = {version = "0.9", default-features = false, features = ["termcolor","humantime","atty"]} +env_logger = { version = "0.9", default-features = false, features = ["termcolor", "humantime", "atty"] } futures-util = { version = "0.3", default_features = false } -getopts = "0.2.21" +getopts = "0.2" hex = "0.4" -hyper = "0.14" log = "0.4" -rpassword = "6.0" +rpassword = "7.0" +sha1 = "0.10" thiserror = "1.0" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "signal", "sync", "process"] } +tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "parking_lot", "process"] } url = "2.2" -sha-1 = "0.9" [features] alsa-backend = ["librespot-playback/alsa-backend"] @@ -72,7 +70,9 @@ rodiojack-backend = ["librespot-playback/rodiojack-backend"] sdl-backend = ["librespot-playback/sdl-backend"] gstreamer-backend = ["librespot-playback/gstreamer-backend"] -with-dns-sd = ["librespot-discovery/with-dns-sd"] +with-dns-sd = ["librespot-core/with-dns-sd", "librespot-discovery/with-dns-sd"] + +passthrough-decoder = ["librespot-playback/passthrough-decoder"] default = ["rodio-backend"] diff --git a/audio/Cargo.toml b/audio/Cargo.toml index d6dc8d7e..18467d36 100644 --- a/audio/Cargo.toml +++ b/audio/Cargo.toml @@ -12,10 +12,15 @@ path = "../core" version = "0.4.2" [dependencies] -aes-ctr = "0.6" +aes = "0.8" byteorder = "1.4" -bytes = "1.0" +bytes = "1" +ctr = "0.9" +futures-core = "0.3" +futures-util = "0.3" +hyper = { version = "0.14", features = ["client"] } log = "0.4" -futures-util = { version = "0.3", default_features = false } -tempfile = "3.1" -tokio = { version = "1", features = ["sync", "macros"] } +parking_lot = { version = "0.12", features = ["deadlock_detection"] } +tempfile = "3" +thiserror = "1.0" +tokio = { version = "1", features = ["macros", "parking_lot", "sync"] } diff --git a/audio/src/decrypt.rs b/audio/src/decrypt.rs index 17f4edba..365ec46e 100644 --- a/audio/src/decrypt.rs +++ b/audio/src/decrypt.rs @@ -1,8 +1,8 @@ use std::io; -use aes_ctr::cipher::generic_array::GenericArray; -use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher, SyncStreamCipherSeek}; -use aes_ctr::Aes128Ctr; +use aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; + +type Aes128Ctr = ctr::Ctr128BE; use librespot_core::audio_key::AudioKey; @@ -11,16 +11,20 @@ const AUDIO_AESIV: [u8; 16] = [ ]; pub struct AudioDecrypt { - cipher: Aes128Ctr, + // a `None` cipher is a convenience to make `AudioDecrypt` pass files unaltered + cipher: Option, reader: T, } impl AudioDecrypt { - pub fn new(key: AudioKey, reader: T) -> AudioDecrypt { - let cipher = Aes128Ctr::new( - GenericArray::from_slice(&key.0), - GenericArray::from_slice(&AUDIO_AESIV), - ); + pub fn new(key: Option, reader: T) -> AudioDecrypt { + let cipher = if let Some(key) = key { + Aes128Ctr::new_from_slices(&key.0, &AUDIO_AESIV).ok() + } else { + // some files are unencrypted + None + }; + AudioDecrypt { cipher, reader } } } @@ -29,7 +33,9 @@ impl io::Read for AudioDecrypt { fn read(&mut self, output: &mut [u8]) -> io::Result { let len = self.reader.read(output)?; - self.cipher.apply_keystream(&mut output[..len]); + if let Some(ref mut cipher) = self.cipher { + cipher.apply_keystream(&mut output[..len]); + } Ok(len) } @@ -39,7 +45,9 @@ impl io::Seek for AudioDecrypt { fn seek(&mut self, pos: io::SeekFrom) -> io::Result { let newpos = self.reader.seek(pos)?; - self.cipher.seek(newpos); + if let Some(ref mut cipher) = self.cipher { + cipher.seek(newpos); + } Ok(newpos) } diff --git a/audio/src/fetch/mod.rs b/audio/src/fetch/mod.rs index 636194a8..30b8d859 100644 --- a/audio/src/fetch/mod.rs +++ b/audio/src/fetch/mod.rs @@ -1,40 +1,82 @@ mod receive; -use std::cmp::{max, min}; -use std::fs; -use std::io::{self, Read, Seek, SeekFrom}; -use std::sync::atomic::{self, AtomicUsize}; -use std::sync::{Arc, Condvar, Mutex}; -use std::time::{Duration, Instant}; +use std::{ + cmp::{max, min}, + fs, + io::{self, Read, Seek, SeekFrom}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; -use byteorder::{BigEndian, ByteOrder}; -use futures_util::{future, StreamExt, TryFutureExt, TryStreamExt}; -use librespot_core::channel::{ChannelData, ChannelError, ChannelHeaders}; -use librespot_core::session::Session; -use librespot_core::spotify_id::FileId; +use futures_util::{future::IntoStream, StreamExt, TryFutureExt}; +use hyper::{client::ResponseFuture, header::CONTENT_RANGE, Body, Response, StatusCode}; +use parking_lot::{Condvar, Mutex}; use tempfile::NamedTempFile; +use thiserror::Error; use tokio::sync::{mpsc, oneshot}; -use self::receive::{audio_file_fetch, request_range}; +use librespot_core::{cdn_url::CdnUrl, Error, FileId, Session}; + +use self::receive::audio_file_fetch; + use crate::range_set::{Range, RangeSet}; +pub type AudioFileResult = Result<(), librespot_core::Error>; + +#[derive(Error, Debug)] +pub enum AudioFileError { + #[error("other end of channel disconnected")] + Channel, + #[error("required header not found")] + Header, + #[error("streamer received no data")] + NoData, + #[error("no output available")] + Output, + #[error("invalid status code {0}")] + StatusCode(StatusCode), + #[error("wait timeout exceeded")] + WaitTimeout, +} + +impl From for Error { + fn from(err: AudioFileError) -> Self { + match err { + AudioFileError::Channel => Error::aborted(err), + AudioFileError::Header => Error::unavailable(err), + AudioFileError::NoData => Error::unavailable(err), + AudioFileError::Output => Error::aborted(err), + AudioFileError::StatusCode(_) => Error::failed_precondition(err), + AudioFileError::WaitTimeout => Error::deadline_exceeded(err), + } + } +} + /// The minimum size of a block that is requested from the Spotify servers in one request. /// This is the block size that is typically requested while doing a `seek()` on a file. +/// The Symphonia decoder requires this to be a power of 2 and > 32 kB. /// Note: smaller requests can happen if part of the block is downloaded already. -const MINIMUM_DOWNLOAD_SIZE: usize = 1024 * 16; +pub const MINIMUM_DOWNLOAD_SIZE: usize = 1024 * 128; + +/// The minimum network throughput that we expect. Together with the minimum download size, +/// this will determine the time we will wait for a response. +pub const MINIMUM_THROUGHPUT: usize = 8192; /// The amount of data that is requested when initially opening a file. /// Note: if the file is opened to play from the beginning, the amount of data to /// read ahead is requested in addition to this amount. If the file is opened to seek to /// another position, then only this amount is requested on the first request. -const INITIAL_DOWNLOAD_SIZE: usize = 1024 * 16; +pub const INITIAL_DOWNLOAD_SIZE: usize = 1024 * 8; /// The ping time that is used for calculations before a ping time was actually measured. -const INITIAL_PING_TIME_ESTIMATE: Duration = Duration::from_millis(500); +pub const INITIAL_PING_TIME_ESTIMATE: Duration = Duration::from_millis(500); /// If the measured ping time to the Spotify server is larger than this value, it is capped /// to avoid run-away block sizes and pre-fetching. -const MAXIMUM_ASSUMED_PING_TIME: Duration = Duration::from_millis(1500); +pub const MAXIMUM_ASSUMED_PING_TIME: Duration = Duration::from_millis(1500); /// Before playback starts, this many seconds of data must be present. /// Note: the calculations are done using the nominal bitrate of the file. The actual amount @@ -63,7 +105,7 @@ pub const READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS: f32 = 10.0; /// If the amount of data that is pending (requested but not received) is less than a certain amount, /// data is pre-fetched in addition to the read ahead settings above. The threshold for requesting more /// data is calculated as ` < PREFETCH_THRESHOLD_FACTOR * * ` -const PREFETCH_THRESHOLD_FACTOR: f32 = 4.0; +pub const PREFETCH_THRESHOLD_FACTOR: f32 = 4.0; /// Similar to `PREFETCH_THRESHOLD_FACTOR`, but it also takes the current download rate into account. /// The formula used is ` < FAST_PREFETCH_THRESHOLD_FACTOR * * ` @@ -72,16 +114,17 @@ const PREFETCH_THRESHOLD_FACTOR: f32 = 4.0; /// the download rate ramps up. However, this comes at the cost that it might hurt ping time if a seek is /// performed while downloading. Values smaller than `1.0` cause the download rate to collapse and effectively /// only `PREFETCH_THRESHOLD_FACTOR` is in effect. Thus, set to `0.0` if bandwidth saturation is not wanted. -const FAST_PREFETCH_THRESHOLD_FACTOR: f32 = 1.5; +pub const FAST_PREFETCH_THRESHOLD_FACTOR: f32 = 1.5; /// Limit the number of requests that are pending simultaneously before pre-fetching data. Pending -/// requests share bandwidth. Thus, havint too many requests can lead to the one that is needed next +/// requests share bandwidth. Thus, having too many requests can lead to the one that is needed next /// for playback to be delayed leading to a buffer underrun. This limit has the effect that a new /// pre-fetch request is only sent if less than `MAX_PREFETCH_REQUESTS` are pending. -const MAX_PREFETCH_REQUESTS: usize = 4; +pub const MAX_PREFETCH_REQUESTS: usize = 4; /// The time we will wait to obtain status updates on downloading. -const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(1); +pub const DOWNLOAD_TIMEOUT: Duration = + Duration::from_secs((MINIMUM_DOWNLOAD_SIZE / MINIMUM_THROUGHPUT) as u64); pub enum AudioFile { Cached(fs::File), @@ -89,11 +132,20 @@ pub enum AudioFile { } #[derive(Debug)] -enum StreamLoaderCommand { - Fetch(Range), // signal the stream loader to fetch a range of the file - RandomAccessMode(), // optimise download strategy for random access - StreamMode(), // optimise download strategy for streaming - Close(), // terminate and don't load any more data +pub struct StreamingRequest { + streamer: IntoStream, + initial_response: Option>, + offset: usize, + length: usize, + request_time: Instant, +} + +#[derive(Debug)] +pub enum StreamLoaderCommand { + Fetch(Range), // signal the stream loader to fetch a range of the file + RandomAccessMode, // optimise download strategy for random access + StreamMode, // optimise download strategy for streaming + Close, // terminate and don't load any more data } #[derive(Clone)] @@ -113,33 +165,40 @@ impl StreamLoaderController { } pub fn range_available(&self, range: Range) -> bool { - if let Some(ref shared) = self.stream_shared { - let download_status = shared.download_status.lock().unwrap(); + let available = if let Some(ref shared) = self.stream_shared { + let download_status = shared.download_status.lock(); + range.length <= download_status .downloaded .contained_length_from_value(range.start) } else { range.length <= self.len() - range.start - } + }; + + available } pub fn range_to_end_available(&self) -> bool { - self.stream_shared.as_ref().map_or(true, |shared| { - let read_position = shared.read_position.load(atomic::Ordering::Relaxed); - self.range_available(Range::new(read_position, self.len() - read_position)) - }) + match self.stream_shared { + Some(ref shared) => { + let read_position = shared.read_position.load(Ordering::Acquire); + self.range_available(Range::new(read_position, self.len() - read_position)) + } + None => true, + } } pub fn ping_time(&self) -> Duration { Duration::from_millis(self.stream_shared.as_ref().map_or(0, |shared| { - shared.ping_time_ms.load(atomic::Ordering::Relaxed) as u64 + shared.ping_time_ms.load(Ordering::Relaxed) as u64 })) } fn send_stream_loader_command(&self, command: StreamLoaderCommand) { if let Some(ref channel) = self.channel_tx { - // ignore the error in case the channel has been closed already. + // Ignore the error in case the channel has been closed already. + // This means that the file was completely downloaded. let _ = channel.send(command); } } @@ -149,7 +208,7 @@ impl StreamLoaderController { self.send_stream_loader_command(StreamLoaderCommand::Fetch(range)); } - pub fn fetch_blocking(&self, mut range: Range) { + pub fn fetch_blocking(&self, mut range: Range) -> AudioFileResult { // signal the stream loader to tech a range of the file and block until it is loaded. // ensure the range is within the file's bounds. @@ -162,17 +221,21 @@ impl StreamLoaderController { self.fetch(range); if let Some(ref shared) = self.stream_shared { - let mut download_status = shared.download_status.lock().unwrap(); + let mut download_status = shared.download_status.lock(); + while range.length > download_status .downloaded .contained_length_from_value(range.start) { - download_status = shared + if shared .cond - .wait_timeout(download_status, DOWNLOAD_TIMEOUT) - .unwrap() - .0; + .wait_for(&mut download_status, DOWNLOAD_TIMEOUT) + .timed_out() + { + return Err(AudioFileError::WaitTimeout.into()); + } + if range.length > (download_status .downloaded @@ -185,41 +248,73 @@ impl StreamLoaderController { } } } + + Ok(()) } + #[allow(dead_code)] pub fn fetch_next(&self, length: usize) { if let Some(ref shared) = self.stream_shared { let range = Range { - start: shared.read_position.load(atomic::Ordering::Relaxed), + start: shared.read_position.load(Ordering::Acquire), length, }; - self.fetch(range) + self.fetch(range); } } - pub fn fetch_next_blocking(&self, length: usize) { - if let Some(ref shared) = self.stream_shared { - let range = Range { - start: shared.read_position.load(atomic::Ordering::Relaxed), - length, - }; - self.fetch_blocking(range); + #[allow(dead_code)] + pub fn fetch_next_blocking(&self, length: usize) -> AudioFileResult { + match self.stream_shared { + Some(ref shared) => { + let range = Range { + start: shared.read_position.load(Ordering::Acquire), + length, + }; + self.fetch_blocking(range) + } + None => Ok(()), + } + } + + pub fn fetch_next_and_wait( + &self, + request_length: usize, + wait_length: usize, + ) -> AudioFileResult { + match self.stream_shared { + Some(ref shared) => { + let start = shared.read_position.load(Ordering::Acquire); + + let request_range = Range { + start, + length: request_length, + }; + self.fetch(request_range); + + let wait_range = Range { + start, + length: wait_length, + }; + self.fetch_blocking(wait_range) + } + None => Ok(()), } } pub fn set_random_access_mode(&self) { // optimise download strategy for random access - self.send_stream_loader_command(StreamLoaderCommand::RandomAccessMode()); + self.send_stream_loader_command(StreamLoaderCommand::RandomAccessMode); } pub fn set_stream_mode(&self) { // optimise download strategy for streaming - self.send_stream_loader_command(StreamLoaderCommand::StreamMode()); + self.send_stream_loader_command(StreamLoaderCommand::StreamMode); } pub fn close(&self) { // terminate stream loading and don't load any more data for this file. - self.send_stream_loader_command(StreamLoaderCommand::Close()); + self.send_stream_loader_command(StreamLoaderCommand::Close); } } @@ -235,19 +330,13 @@ struct AudioFileDownloadStatus { downloaded: RangeSet, } -#[derive(Copy, Clone, PartialEq, Eq)] -enum DownloadStrategy { - RandomAccess(), - Streaming(), -} - struct AudioFileShared { - file_id: FileId, + cdn_url: CdnUrl, file_size: usize, - stream_data_rate: usize, + bytes_per_second: usize, cond: Condvar, download_status: Mutex, - download_strategy: Mutex, + download_streaming: AtomicBool, number_of_open_requests: AtomicUsize, ping_time_ms: AtomicUsize, read_position: AtomicUsize, @@ -258,8 +347,7 @@ impl AudioFile { session: &Session, file_id: FileId, bytes_per_second: usize, - play_from_beginning: bool, - ) -> Result { + ) -> Result { if let Some(file) = session.cache().and_then(|cache| cache.file(file_id)) { debug!("File {} already in cache", file_id); return Ok(AudioFile::Cached(file)); @@ -268,48 +356,30 @@ impl AudioFile { debug!("Downloading file {}", file_id); let (complete_tx, complete_rx) = oneshot::channel(); - let mut initial_data_length = if play_from_beginning { - INITIAL_DOWNLOAD_SIZE - + max( - (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, - (INITIAL_PING_TIME_ESTIMATE.as_secs_f32() - * READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS - * bytes_per_second as f32) as usize, - ) - } else { - INITIAL_DOWNLOAD_SIZE - }; - if initial_data_length % 4 != 0 { - initial_data_length += 4 - (initial_data_length % 4); - } - let (headers, data) = request_range(session, file_id, 0, initial_data_length).split(); - let streaming = AudioFileStreaming::open( - session.clone(), - data, - initial_data_length, - Instant::now(), - headers, - file_id, - complete_tx, - bytes_per_second, - ); + let streaming = + AudioFileStreaming::open(session.clone(), file_id, complete_tx, bytes_per_second); let session_ = session.clone(); session.spawn(complete_rx.map_ok(move |mut file| { + debug!("Downloading file {} complete", file_id); + if let Some(cache) = session_.cache() { - debug!("File {} complete, saving to cache", file_id); - cache.save_file(file_id, &mut file); - } else { - debug!("File {} complete", file_id); + if let Some(cache_id) = cache.file_path(file_id) { + if let Err(e) = cache.save_file(file_id, &mut file) { + error!("Error caching file {} to {:?}: {}", file_id, cache_id, e); + } else { + debug!("File {} cached to {:?}", file_id, cache_id); + } + } } })); Ok(AudioFile::Streaming(streaming.await?)) } - pub fn get_stream_loader_controller(&self) -> StreamLoaderController { - match self { + pub fn get_stream_loader_controller(&self) -> Result { + let controller = match self { AudioFile::Streaming(ref stream) => StreamLoaderController { channel_tx: Some(stream.stream_loader_command_tx.clone()), stream_shared: Some(stream.shared.clone()), @@ -318,9 +388,11 @@ impl AudioFile { AudioFile::Cached(ref file) => StreamLoaderController { channel_tx: None, stream_shared: None, - file_size: file.metadata().unwrap().len() as usize, + file_size: file.metadata()?.len() as usize, }, - } + }; + + Ok(controller) } pub fn is_cached(&self) -> bool { @@ -331,53 +403,85 @@ impl AudioFile { impl AudioFileStreaming { pub async fn open( session: Session, - initial_data_rx: ChannelData, - initial_data_length: usize, - initial_request_sent_time: Instant, - headers: ChannelHeaders, file_id: FileId, complete_tx: oneshot::Sender, - streaming_data_rate: usize, - ) -> Result { - let (_, data) = headers - .try_filter(|(id, _)| future::ready(*id == 0x3)) - .next() - .await - .unwrap()?; + bytes_per_second: usize, + ) -> Result { + let cdn_url = CdnUrl::new(file_id).resolve_audio(&session).await?; - let size = BigEndian::read_u32(&data) as usize * 4; + if let Ok(url) = cdn_url.try_get_url() { + trace!("Streaming from {}", url); + } + + // When the audio file is really small, this `download_size` may turn out to be + // larger than the audio file we're going to stream later on. This is OK; requesting + // `Content-Range` > `Content-Length` will return the complete file with status code + // 206 Partial Content. + let mut streamer = + session + .spclient() + .stream_from_cdn(&cdn_url, 0, INITIAL_DOWNLOAD_SIZE)?; + + // Get the first chunk with the headers to get the file size. + // The remainder of that chunk with possibly also a response body is then + // further processed in `audio_file_fetch`. + let request_time = Instant::now(); + let response = streamer.next().await.ok_or(AudioFileError::NoData)??; + + let code = response.status(); + if code != StatusCode::PARTIAL_CONTENT { + debug!( + "Opening audio file expected partial content but got: {}", + code + ); + return Err(AudioFileError::StatusCode(code).into()); + } + + let header_value = response + .headers() + .get(CONTENT_RANGE) + .ok_or(AudioFileError::Header)?; + let str_value = header_value.to_str()?; + let hyphen_index = str_value.find('-').unwrap_or_default(); + let slash_index = str_value.find('/').unwrap_or_default(); + let upper_bound: usize = str_value[hyphen_index + 1..slash_index].parse()?; + let file_size = str_value[slash_index + 1..].parse()?; + + let initial_request = StreamingRequest { + streamer, + initial_response: Some(response), + offset: 0, + length: upper_bound + 1, + request_time, + }; let shared = Arc::new(AudioFileShared { - file_id, - file_size: size, - stream_data_rate: streaming_data_rate, + cdn_url, + file_size, + bytes_per_second, cond: Condvar::new(), download_status: Mutex::new(AudioFileDownloadStatus { requested: RangeSet::new(), downloaded: RangeSet::new(), }), - download_strategy: Mutex::new(DownloadStrategy::RandomAccess()), // start with random access mode until someone tells us otherwise + download_streaming: AtomicBool::new(true), number_of_open_requests: AtomicUsize::new(0), - ping_time_ms: AtomicUsize::new(0), + ping_time_ms: AtomicUsize::new(INITIAL_PING_TIME_ESTIMATE.as_millis() as usize), read_position: AtomicUsize::new(0), }); - let mut write_file = NamedTempFile::new().unwrap(); - write_file.as_file().set_len(size as u64).unwrap(); - write_file.seek(SeekFrom::Start(0)).unwrap(); + let write_file = NamedTempFile::new_in(session.config().tmp_dir.clone())?; + write_file.as_file().set_len(file_size as u64)?; - let read_file = write_file.reopen().unwrap(); + let read_file = write_file.reopen()?; - // let (seek_tx, seek_rx) = mpsc::unbounded(); let (stream_loader_command_tx, stream_loader_command_rx) = mpsc::unbounded_channel::(); session.spawn(audio_file_fetch( session.clone(), shared.clone(), - initial_data_rx, - initial_request_sent_time, - initial_data_length, + initial_request, write_file, stream_loader_command_rx, complete_tx, @@ -401,83 +505,70 @@ impl Read for AudioFileStreaming { } let length = min(output.len(), self.shared.file_size - offset); + if length == 0 { + return Ok(0); + } - let length_to_request = match *(self.shared.download_strategy.lock().unwrap()) { - DownloadStrategy::RandomAccess() => length, - DownloadStrategy::Streaming() => { - // Due to the read-ahead stuff, we potentially request more than the actual request demanded. - let ping_time_seconds = Duration::from_millis( - self.shared.ping_time_ms.load(atomic::Ordering::Relaxed) as u64, - ) - .as_secs_f32(); + let length_to_request = if self.shared.download_streaming.load(Ordering::Acquire) { + // Due to the read-ahead stuff, we potentially request more than the actual request demanded. + let ping_time_seconds = + Duration::from_millis(self.shared.ping_time_ms.load(Ordering::Relaxed) as u64) + .as_secs_f32(); - let length_to_request = length - + max( - (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() - * self.shared.stream_data_rate as f32) as usize, - (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS - * ping_time_seconds - * self.shared.stream_data_rate as f32) as usize, - ); - min(length_to_request, self.shared.file_size - offset) - } + let length_to_request = length + + max( + (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * self.shared.bytes_per_second as f32) + as usize, + (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS + * ping_time_seconds + * self.shared.bytes_per_second as f32) as usize, + ); + min(length_to_request, self.shared.file_size - offset) + } else { + length }; let mut ranges_to_request = RangeSet::new(); ranges_to_request.add_range(&Range::new(offset, length_to_request)); - let mut download_status = self.shared.download_status.lock().unwrap(); + let mut download_status = self.shared.download_status.lock(); + ranges_to_request.subtract_range_set(&download_status.downloaded); ranges_to_request.subtract_range_set(&download_status.requested); for &range in ranges_to_request.iter() { self.stream_loader_command_tx .send(StreamLoaderCommand::Fetch(range)) - .unwrap(); + .map_err(|err| io::Error::new(io::ErrorKind::BrokenPipe, err))?; } - if length == 0 { - return Ok(0); - } - - let mut download_message_printed = false; while !download_status.downloaded.contains(offset) { - if let DownloadStrategy::Streaming() = *self.shared.download_strategy.lock().unwrap() { - if !download_message_printed { - debug!("Stream waiting for download of file position {}. Downloaded ranges: {}. Pending ranges: {}", offset, download_status.downloaded, download_status.requested.minus(&download_status.downloaded)); - download_message_printed = true; - } - } - download_status = self + if self .shared .cond - .wait_timeout(download_status, DOWNLOAD_TIMEOUT) - .unwrap() - .0; + .wait_for(&mut download_status, DOWNLOAD_TIMEOUT) + .timed_out() + { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + Error::deadline_exceeded(AudioFileError::WaitTimeout), + )); + } } let available_length = download_status .downloaded .contained_length_from_value(offset); - assert!(available_length > 0); + drop(download_status); - self.position = self.read_file.seek(SeekFrom::Start(offset as u64)).unwrap(); + self.position = self.read_file.seek(SeekFrom::Start(offset as u64))?; let read_len = min(length, available_length); let read_len = self.read_file.read(&mut output[..read_len])?; - if download_message_printed { - debug!( - "Read at postion {} completed. {} bytes returned, {} bytes were requested.", - offset, - read_len, - output.len() - ); - } - self.position += read_len as u64; self.shared .read_position - .store(self.position as usize, atomic::Ordering::Relaxed); + .store(self.position as usize, Ordering::Release); Ok(read_len) } @@ -485,11 +576,50 @@ impl Read for AudioFileStreaming { impl Seek for AudioFileStreaming { fn seek(&mut self, pos: SeekFrom) -> io::Result { + // If we are already at this position, we don't need to switch download mode. + // These checks and locks are less expensive than interrupting streaming. + let current_position = self.position as i64; + let requested_pos = match pos { + SeekFrom::Start(pos) => pos as i64, + SeekFrom::End(pos) => self.shared.file_size as i64 - pos - 1, + SeekFrom::Current(pos) => current_position + pos, + }; + if requested_pos == current_position { + return Ok(current_position as u64); + } + + // Again if we have already downloaded this part. + let available = self + .shared + .download_status + .lock() + .downloaded + .contains(requested_pos as usize); + + let mut was_streaming = false; + if !available { + // Ensure random access mode if we need to download this part. + // Checking whether we are streaming now is a micro-optimization + // to save an atomic load. + was_streaming = self.shared.download_streaming.load(Ordering::Acquire); + if was_streaming { + self.shared + .download_streaming + .store(false, Ordering::Release); + } + } + self.position = self.read_file.seek(pos)?; - // Do not seek past EOF self.shared .read_position - .store(self.position as usize, atomic::Ordering::Relaxed); + .store(self.position as usize, Ordering::Release); + + if !available && was_streaming { + self.shared + .download_streaming + .store(true, Ordering::Release); + } + Ok(self.position) } } diff --git a/audio/src/fetch/receive.rs b/audio/src/fetch/receive.rs index f7574f4f..af12810c 100644 --- a/audio/src/fetch/receive.rs +++ b/audio/src/fetch/receive.rs @@ -1,57 +1,26 @@ -use std::cmp::{max, min}; -use std::io::{Seek, SeekFrom, Write}; -use std::sync::{atomic, Arc}; -use std::time::{Duration, Instant}; +use std::{ + cmp::{max, min}, + io::{Seek, SeekFrom, Write}, + sync::{atomic::Ordering, Arc}, + time::{Duration, Instant}, +}; -use atomic::Ordering; -use byteorder::{BigEndian, WriteBytesExt}; use bytes::Bytes; use futures_util::StreamExt; -use librespot_core::channel::{Channel, ChannelData}; -use librespot_core::session::Session; -use librespot_core::spotify_id::FileId; +use hyper::StatusCode; use tempfile::NamedTempFile; use tokio::sync::{mpsc, oneshot}; +use librespot_core::{session::Session, Error}; + use crate::range_set::{Range, RangeSet}; -use super::{AudioFileShared, DownloadStrategy, StreamLoaderCommand}; use super::{ + AudioFileError, AudioFileResult, AudioFileShared, StreamLoaderCommand, StreamingRequest, FAST_PREFETCH_THRESHOLD_FACTOR, MAXIMUM_ASSUMED_PING_TIME, MAX_PREFETCH_REQUESTS, MINIMUM_DOWNLOAD_SIZE, PREFETCH_THRESHOLD_FACTOR, }; -pub fn request_range(session: &Session, file: FileId, offset: usize, length: usize) -> Channel { - assert!( - offset % 4 == 0, - "Range request start positions must be aligned by 4 bytes." - ); - assert!( - length % 4 == 0, - "Range request range lengths must be aligned by 4 bytes." - ); - let start = offset / 4; - let end = (offset + length) / 4; - - let (id, channel) = session.channel().allocate(); - - let mut data: Vec = Vec::new(); - data.write_u16::(id).unwrap(); - data.write_u8(0).unwrap(); - data.write_u8(1).unwrap(); - data.write_u16::(0x0000).unwrap(); - data.write_u32::(0x00000000).unwrap(); - data.write_u32::(0x00009C40).unwrap(); - data.write_u32::(0x00020000).unwrap(); - data.write(&file.0).unwrap(); - data.write_u32::(start as u32).unwrap(); - data.write_u32::(end as u32).unwrap(); - - session.send_packet(0x8, data); - - channel -} - struct PartialFileData { offset: usize, data: Bytes, @@ -65,13 +34,10 @@ enum ReceivedData { async fn receive_data( shared: Arc, file_data_tx: mpsc::UnboundedSender, - mut data_rx: ChannelData, - initial_data_offset: usize, - initial_request_length: usize, - request_sent_time: Instant, -) { - let mut data_offset = initial_data_offset; - let mut request_length = initial_request_length; + mut request: StreamingRequest, +) -> AudioFileResult { + let mut offset = request.offset; + let mut actual_length = 0; let old_number_of_request = shared .number_of_open_requests @@ -79,65 +45,80 @@ async fn receive_data( let mut measure_ping_time = old_number_of_request == 0; - let result = loop { - let data = match data_rx.next().await { - Some(Ok(data)) => data, - Some(Err(e)) => break Err(e), - None => break Ok(()), + let result: Result<_, Error> = loop { + let response = match request.initial_response.take() { + Some(data) => data, + None => match request.streamer.next().await { + Some(Ok(response)) => response, + Some(Err(e)) => break Err(e.into()), + None => { + if actual_length != request.length { + let msg = format!("did not expect body to contain {} bytes", actual_length); + break Err(Error::data_loss(msg)); + } + + break Ok(()); + } + }, }; - if measure_ping_time { - let mut duration = Instant::now() - request_sent_time; - if duration > MAXIMUM_ASSUMED_PING_TIME { - duration = MAXIMUM_ASSUMED_PING_TIME; - } - let _ = file_data_tx.send(ReceivedData::ResponseTime(duration)); - measure_ping_time = false; - } - let data_size = data.len(); - let _ = file_data_tx.send(ReceivedData::Data(PartialFileData { - offset: data_offset, - data, - })); - data_offset += data_size; - if request_length < data_size { - warn!( - "Data receiver for range {} (+{}) received more data from server than requested.", - initial_data_offset, initial_request_length - ); - request_length = 0; - } else { - request_length -= data_size; + let code = response.status(); + if code != StatusCode::PARTIAL_CONTENT { + break Err(AudioFileError::StatusCode(code).into()); } - if request_length == 0 { - break Ok(()); + let body = response.into_body(); + let data = match hyper::body::to_bytes(body).await { + Ok(bytes) => bytes, + Err(e) => break Err(e.into()), + }; + + let data_size = data.len(); + file_data_tx.send(ReceivedData::Data(PartialFileData { offset, data }))?; + + actual_length += data_size; + offset += data_size; + + if measure_ping_time { + let mut duration = Instant::now() - request.request_time; + if duration > MAXIMUM_ASSUMED_PING_TIME { + warn!( + "Ping time {} ms exceeds maximum {}, setting to maximum", + duration.as_millis(), + MAXIMUM_ASSUMED_PING_TIME.as_millis() + ); + duration = MAXIMUM_ASSUMED_PING_TIME; + } + file_data_tx.send(ReceivedData::ResponseTime(duration))?; + measure_ping_time = false; } }; - if request_length > 0 { - let missing_range = Range::new(data_offset, request_length); + drop(request.streamer); - let mut download_status = shared.download_status.lock().unwrap(); - download_status.requested.subtract_range(&missing_range); - shared.cond.notify_all(); + let bytes_remaining = request.length - actual_length; + if bytes_remaining > 0 { + { + let missing_range = Range::new(offset, bytes_remaining); + let mut download_status = shared.download_status.lock(); + download_status.requested.subtract_range(&missing_range); + shared.cond.notify_all(); + } } shared .number_of_open_requests .fetch_sub(1, Ordering::SeqCst); - if result.is_err() { - warn!( - "Error from channel for data receiver for range {} (+{}).", - initial_data_offset, initial_request_length - ); - } else if request_length > 0 { - warn!( - "Data receiver for range {} (+{}) received less data from server than requested.", - initial_data_offset, initial_request_length + if let Err(e) = result { + error!( + "Streamer error requesting range {} +{}: {:?}", + request.offset, request.length, e ); + return Err(e); } + + Ok(()) } struct AudioFileFetch { @@ -158,68 +139,64 @@ enum ControlFlow { } impl AudioFileFetch { - fn get_download_strategy(&mut self) -> DownloadStrategy { - *(self.shared.download_strategy.lock().unwrap()) + fn is_download_streaming(&self) -> bool { + self.shared.download_streaming.load(Ordering::Acquire) } - fn download_range(&mut self, mut offset: usize, mut length: usize) { + fn download_range(&mut self, offset: usize, mut length: usize) -> AudioFileResult { if length < MINIMUM_DOWNLOAD_SIZE { length = MINIMUM_DOWNLOAD_SIZE; } - // ensure the values are within the bounds and align them by 4 for the spotify protocol. - if offset >= self.shared.file_size { - return; - } - - if length == 0 { - return; - } - if offset + length > self.shared.file_size { length = self.shared.file_size - offset; } - if offset % 4 != 0 { - length += offset % 4; - offset -= offset % 4; - } - - if length % 4 != 0 { - length += 4 - (length % 4); - } - let mut ranges_to_request = RangeSet::new(); ranges_to_request.add_range(&Range::new(offset, length)); - let mut download_status = self.shared.download_status.lock().unwrap(); + // The iteration that follows spawns streamers fast, without awaiting them, + // so holding the lock for the entire scope of this function should be faster + // then locking and unlocking multiple times. + let mut download_status = self.shared.download_status.lock(); ranges_to_request.subtract_range_set(&download_status.downloaded); ranges_to_request.subtract_range_set(&download_status.requested); + // TODO : refresh cdn_url when the token expired + for range in ranges_to_request.iter() { - let (_headers, data) = request_range( - &self.session, - self.shared.file_id, + let streamer = self.session.spclient().stream_from_cdn( + &self.shared.cdn_url, range.start, range.length, - ) - .split(); + )?; download_status.requested.add_range(range); + let streaming_request = StreamingRequest { + streamer, + initial_response: None, + offset: range.start, + length: range.length, + request_time: Instant::now(), + }; + self.session.spawn(receive_data( self.shared.clone(), self.file_data_tx.clone(), - data, - range.start, - range.length, - Instant::now(), + streaming_request, )); } + + Ok(()) } - fn pre_fetch_more_data(&mut self, bytes: usize, max_requests_to_send: usize) { + fn pre_fetch_more_data( + &mut self, + bytes: usize, + max_requests_to_send: usize, + ) -> AudioFileResult { let mut bytes_to_go = bytes; let mut requests_to_go = max_requests_to_send; @@ -228,14 +205,14 @@ impl AudioFileFetch { let mut missing_data = RangeSet::new(); missing_data.add_range(&Range::new(0, self.shared.file_size)); { - let download_status = self.shared.download_status.lock().unwrap(); + let download_status = self.shared.download_status.lock(); missing_data.subtract_range_set(&download_status.downloaded); missing_data.subtract_range_set(&download_status.requested); } // download data from after the current read position first let mut tail_end = RangeSet::new(); - let read_position = self.shared.read_position.load(Ordering::Relaxed); + let read_position = self.shared.read_position.load(Ordering::Acquire); tail_end.add_range(&Range::new( read_position, self.shared.file_size - read_position, @@ -246,7 +223,7 @@ impl AudioFileFetch { let range = tail_end.get_range(0); let offset = range.start; let length = min(range.length, bytes_to_go); - self.download_range(offset, length); + self.download_range(offset, length)?; requests_to_go -= 1; bytes_to_go -= length; } else if !missing_data.is_empty() { @@ -254,20 +231,21 @@ impl AudioFileFetch { let range = missing_data.get_range(0); let offset = range.start; let length = min(range.length, bytes_to_go); - self.download_range(offset, length); + self.download_range(offset, length)?; requests_to_go -= 1; bytes_to_go -= length; } else { - return; + break; } } + + Ok(()) } - fn handle_file_data(&mut self, data: ReceivedData) -> ControlFlow { + fn handle_file_data(&mut self, data: ReceivedData) -> Result { match data { ReceivedData::ResponseTime(response_time) => { - // chatty - // trace!("Ping time estimated as: {}ms", response_time.as_millis()); + let old_ping_time_ms = self.shared.ping_time_ms.load(Ordering::Relaxed); // prune old response times. Keep at most two so we can push a third. while self.network_response_times.len() >= 3 { @@ -278,109 +256,131 @@ impl AudioFileFetch { self.network_response_times.push(response_time); // stats::median is experimental. So we calculate the median of up to three ourselves. - let ping_time = match self.network_response_times.len() { - 1 => self.network_response_times[0], - 2 => (self.network_response_times[0] + self.network_response_times[1]) / 2, - 3 => { - let mut times = self.network_response_times.clone(); - times.sort_unstable(); - times[1] - } - _ => unreachable!(), + let ping_time_ms = { + let response_time = match self.network_response_times.len() { + 1 => self.network_response_times[0], + 2 => (self.network_response_times[0] + self.network_response_times[1]) / 2, + 3 => { + let mut times = self.network_response_times.clone(); + times.sort_unstable(); + times[1] + } + _ => unreachable!(), + }; + response_time.as_millis() as usize }; + // print when the new estimate deviates by more than 10% from the last + if f32::abs( + (ping_time_ms as f32 - old_ping_time_ms as f32) / old_ping_time_ms as f32, + ) > 0.1 + { + debug!("Ping time now estimated as: {} ms", ping_time_ms); + } + // store our new estimate for everyone to see self.shared .ping_time_ms - .store(ping_time.as_millis() as usize, Ordering::Relaxed); + .store(ping_time_ms, Ordering::Relaxed); } ReceivedData::Data(data) => { - self.output - .as_mut() - .unwrap() - .seek(SeekFrom::Start(data.offset as u64)) - .unwrap(); - self.output - .as_mut() - .unwrap() - .write_all(data.data.as_ref()) - .unwrap(); - - let mut download_status = self.shared.download_status.lock().unwrap(); + match self.output.as_mut() { + Some(output) => { + output.seek(SeekFrom::Start(data.offset as u64))?; + output.write_all(data.data.as_ref())?; + } + None => return Err(AudioFileError::Output.into()), + } let received_range = Range::new(data.offset, data.data.len()); - download_status.downloaded.add_range(&received_range); - self.shared.cond.notify_all(); - let full = download_status.downloaded.contained_length_from_value(0) - >= self.shared.file_size; + let full = { + let mut download_status = self.shared.download_status.lock(); + download_status.downloaded.add_range(&received_range); + self.shared.cond.notify_all(); - drop(download_status); + download_status.downloaded.contained_length_from_value(0) + >= self.shared.file_size + }; if full { - self.finish(); - return ControlFlow::Break; + self.finish()?; + return Ok(ControlFlow::Break); } } } - ControlFlow::Continue + + Ok(ControlFlow::Continue) } - fn handle_stream_loader_command(&mut self, cmd: StreamLoaderCommand) -> ControlFlow { + fn handle_stream_loader_command( + &mut self, + cmd: StreamLoaderCommand, + ) -> Result { match cmd { StreamLoaderCommand::Fetch(request) => { - self.download_range(request.start, request.length); + self.download_range(request.start, request.length)? } - StreamLoaderCommand::RandomAccessMode() => { - *(self.shared.download_strategy.lock().unwrap()) = DownloadStrategy::RandomAccess(); - } - StreamLoaderCommand::StreamMode() => { - *(self.shared.download_strategy.lock().unwrap()) = DownloadStrategy::Streaming(); - } - StreamLoaderCommand::Close() => return ControlFlow::Break, + StreamLoaderCommand::RandomAccessMode => self + .shared + .download_streaming + .store(false, Ordering::Release), + StreamLoaderCommand::StreamMode => self + .shared + .download_streaming + .store(true, Ordering::Release), + StreamLoaderCommand::Close => return Ok(ControlFlow::Break), } - ControlFlow::Continue + + Ok(ControlFlow::Continue) } - fn finish(&mut self) { - let mut output = self.output.take().unwrap(); - let complete_tx = self.complete_tx.take().unwrap(); + fn finish(&mut self) -> AudioFileResult { + let output = self.output.take(); - output.seek(SeekFrom::Start(0)).unwrap(); - let _ = complete_tx.send(output); + let complete_tx = self.complete_tx.take(); + + if let Some(mut output) = output { + output.seek(SeekFrom::Start(0))?; + if let Some(complete_tx) = complete_tx { + complete_tx + .send(output) + .map_err(|_| AudioFileError::Channel)?; + } + } + + Ok(()) } } pub(super) async fn audio_file_fetch( session: Session, shared: Arc, - initial_data_rx: ChannelData, - initial_request_sent_time: Instant, - initial_data_length: usize, - + initial_request: StreamingRequest, output: NamedTempFile, mut stream_loader_command_rx: mpsc::UnboundedReceiver, complete_tx: oneshot::Sender, -) { +) -> AudioFileResult { let (file_data_tx, mut file_data_rx) = mpsc::unbounded_channel(); { - let requested_range = Range::new(0, initial_data_length); - let mut download_status = shared.download_status.lock().unwrap(); + let requested_range = Range::new( + initial_request.offset, + initial_request.offset + initial_request.length, + ); + + let mut download_status = shared.download_status.lock(); download_status.requested.add_range(&requested_range); } session.spawn(receive_data( shared.clone(), file_data_tx.clone(), - initial_data_rx, - 0, - initial_data_length, - initial_request_sent_time, + initial_request, )); let mut fetch = AudioFileFetch { - session, + session: session.clone(), shared, output: Some(output), @@ -392,25 +392,37 @@ pub(super) async fn audio_file_fetch( loop { tokio::select! { cmd = stream_loader_command_rx.recv() => { - if cmd.map_or(true, |cmd| fetch.handle_stream_loader_command(cmd) == ControlFlow::Break) { - break; + match cmd { + Some(cmd) => { + if fetch.handle_stream_loader_command(cmd)? == ControlFlow::Break { + break; + } + } + None => break, + } + } + data = file_data_rx.recv() => { + match data { + Some(data) => { + if fetch.handle_file_data(data)? == ControlFlow::Break { + break; + } + } + None => break, } }, - data = file_data_rx.recv() => { - if data.map_or(true, |data| fetch.handle_file_data(data) == ControlFlow::Break) { - break; - } - } + else => (), } - if fetch.get_download_strategy() == DownloadStrategy::Streaming() { + if fetch.is_download_streaming() { let number_of_open_requests = fetch.shared.number_of_open_requests.load(Ordering::SeqCst); if number_of_open_requests < MAX_PREFETCH_REQUESTS { let max_requests_to_send = MAX_PREFETCH_REQUESTS - number_of_open_requests; let bytes_pending: usize = { - let download_status = fetch.shared.download_status.lock().unwrap(); + let download_status = fetch.shared.download_status.lock(); + download_status .requested .minus(&download_status.downloaded) @@ -425,7 +437,7 @@ pub(super) async fn audio_file_fetch( let desired_pending_bytes = max( (PREFETCH_THRESHOLD_FACTOR * ping_time_seconds - * fetch.shared.stream_data_rate as f32) as usize, + * fetch.shared.bytes_per_second as f32) as usize, (FAST_PREFETCH_THRESHOLD_FACTOR * ping_time_seconds * download_rate as f32) as usize, ); @@ -434,9 +446,11 @@ pub(super) async fn audio_file_fetch( fetch.pre_fetch_more_data( desired_pending_bytes - bytes_pending, max_requests_to_send, - ); + )?; } } } } + + Ok(()) } diff --git a/audio/src/lib.rs b/audio/src/lib.rs index 4b486bbe..22bf2f0a 100644 --- a/audio/src/lib.rs +++ b/audio/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unused_io_amount, clippy::too_many_arguments)] - #[macro_use] extern crate log; @@ -9,8 +7,8 @@ mod fetch; mod range_set; pub use decrypt::AudioDecrypt; -pub use fetch::{AudioFile, StreamLoaderController}; +pub use fetch::{AudioFile, AudioFileError, StreamLoaderController}; pub use fetch::{ - READ_AHEAD_BEFORE_PLAYBACK, READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK, - READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, + MINIMUM_DOWNLOAD_SIZE, READ_AHEAD_BEFORE_PLAYBACK, READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, + READ_AHEAD_DURING_PLAYBACK, READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, }; diff --git a/audio/src/range_set.rs b/audio/src/range_set.rs index f74058a3..9c4b0b87 100644 --- a/audio/src/range_set.rs +++ b/audio/src/range_set.rs @@ -1,6 +1,8 @@ -use std::cmp::{max, min}; -use std::fmt; -use std::slice::Iter; +use std::{ + cmp::{max, min}, + fmt, + slice::Iter, +}; #[derive(Copy, Clone, Debug)] pub struct Range { @@ -10,7 +12,7 @@ pub struct Range { impl fmt::Display for Range { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - return write!(f, "[{}, {}]", self.start, self.start + self.length - 1); + write!(f, "[{}, {}]", self.start, self.start + self.length - 1) } } @@ -24,16 +26,16 @@ impl Range { } } -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct RangeSet { ranges: Vec, } impl fmt::Display for RangeSet { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "(").unwrap(); + write!(f, "(")?; for range in self.ranges.iter() { - write!(f, "{}", range).unwrap(); + write!(f, "{}", range)?; } write!(f, ")") } @@ -227,7 +229,6 @@ impl RangeSet { self.ranges[self_index].end(), other.ranges[other_index].end(), ); - assert!(new_start <= new_end); result.add_range(&Range::new(new_start, new_end - new_start)); if self.ranges[self_index].end() <= other.ranges[other_index].end() { self_index += 1; diff --git a/connect/Cargo.toml b/connect/Cargo.toml index 313e6440..fd2473fe 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -9,14 +9,15 @@ edition = "2018" [dependencies] form_urlencoded = "1.0" -futures-util = { version = "0.3.5", default_features = false } +futures-util = "0.3" log = "0.4" -protobuf = "2.14.0" +protobuf = "2" rand = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tokio = { version = "1.0", features = ["macros", "sync"] } -tokio-stream = "0.1.1" +thiserror = "1.0" +tokio = { version = "1", features = ["macros", "parking_lot", "sync"] } +tokio-stream = "0.1" [dependencies.librespot-core] path = "../core" @@ -29,10 +30,3 @@ version = "0.4.2" [dependencies.librespot-protocol] path = "../protocol" version = "0.4.2" - -[dependencies.librespot-discovery] -path = "../discovery" -version = "0.4.2" - -[features] -with-dns-sd = ["librespot-discovery/with-dns-sd"] diff --git a/connect/src/config.rs b/connect/src/config.rs new file mode 100644 index 00000000..473fa173 --- /dev/null +++ b/connect/src/config.rs @@ -0,0 +1,20 @@ +use crate::core::config::DeviceType; + +#[derive(Clone, Debug)] +pub struct ConnectConfig { + pub name: String, + pub device_type: DeviceType, + pub initial_volume: Option, + pub has_volume_ctrl: bool, +} + +impl Default for ConnectConfig { + fn default() -> ConnectConfig { + ConnectConfig { + name: "Librespot".to_string(), + device_type: DeviceType::default(), + initial_volume: Some(50), + has_volume_ctrl: true, + } + } +} diff --git a/connect/src/context.rs b/connect/src/context.rs index 154d9507..928aec23 100644 --- a/connect/src/context.rs +++ b/connect/src/context.rs @@ -1,7 +1,12 @@ +// TODO : move to metadata + use crate::core::spotify_id::SpotifyId; use crate::protocol::spirc::TrackRef; -use serde::Deserialize; +use serde::{ + de::{Error, Unexpected}, + Deserialize, +}; #[derive(Deserialize, Debug)] pub struct StationContext { @@ -72,17 +77,23 @@ where D: serde::Deserializer<'d>, { let v: Vec = serde::Deserialize::deserialize(de)?; - let track_vec = v - .iter() + v.iter() .map(|v| { let mut t = TrackRef::new(); // This has got to be the most round about way of doing this. - t.set_gid(SpotifyId::from_base62(&v.gid).unwrap().to_raw().to_vec()); + t.set_gid( + SpotifyId::from_base62(&v.gid) + .map_err(|_| { + D::Error::invalid_value( + Unexpected::Str(&v.gid), + &"a Base-62 encoded Spotify ID", + ) + })? + .to_raw() + .to_vec(), + ); t.set_uri(v.uri.to_owned()); - - t + Ok(t) }) - .collect::>(); - - Ok(track_vec) + .collect::, D::Error>>() } diff --git a/connect/src/discovery.rs b/connect/src/discovery.rs deleted file mode 100644 index 8ce3f4f0..00000000 --- a/connect/src/discovery.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::io; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use futures_util::Stream; -use librespot_core::authentication::Credentials; -use librespot_core::config::ConnectConfig; - -pub struct DiscoveryStream(librespot_discovery::Discovery); - -impl Stream for DiscoveryStream { - type Item = Credentials; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.0).poll_next(cx) - } -} - -pub fn discovery( - config: ConnectConfig, - device_id: String, - port: u16, -) -> io::Result { - librespot_discovery::Discovery::builder(device_id) - .device_type(config.device_type) - .port(port) - .name(config.name) - .launch() - .map(DiscoveryStream) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) -} diff --git a/connect/src/lib.rs b/connect/src/lib.rs index 267bf1b8..193e5db5 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -5,10 +5,6 @@ use librespot_core as core; use librespot_playback as playback; use librespot_protocol as protocol; +pub mod config; pub mod context; -#[deprecated( - since = "0.2.1", - note = "Please use the crate `librespot_discovery` instead." -)] -pub mod discovery; pub mod spirc; diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 698ca46a..1b50c068 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,27 +1,66 @@ -use std::future::Future; -use std::pin::Pin; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + convert::TryFrom, + future::Future, + pin::Pin, + sync::atomic::{AtomicUsize, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; -use crate::context::StationContext; -use crate::core::config::ConnectConfig; -use crate::core::mercury::{MercuryError, MercurySender}; -use crate::core::session::Session; -use crate::core::spotify_id::{SpotifyAudioType, SpotifyId, SpotifyIdError}; -use crate::core::util::SeqGenerator; -use crate::core::version; -use crate::playback::mixer::Mixer; -use crate::playback::player::{Player, PlayerEvent, PlayerEventChannel}; -use crate::protocol; -use crate::protocol::spirc::{DeviceState, Frame, MessageType, PlayStatus, State, TrackRef}; +use futures_util::{ + future::{self, FusedFuture}, + stream::FusedStream, + FutureExt, StreamExt, +}; -use futures_util::future::{self, FusedFuture}; -use futures_util::stream::FusedStream; -use futures_util::{FutureExt, StreamExt}; use protobuf::{self, Message}; use rand::seq::SliceRandom; +use thiserror::Error; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; +use crate::{ + config::ConnectConfig, + context::StationContext, + core::{ + authentication::Credentials, + mercury::{MercuryError, MercurySender}, + session::UserAttributes, + util::SeqGenerator, + version, Error, Session, SpotifyId, + }, + playback::{ + mixer::Mixer, + player::{Player, PlayerEvent, PlayerEventChannel}, + }, + protocol::{ + self, + explicit_content_pubsub::UserAttributesUpdate, + spirc::{DeviceState, Frame, MessageType, PlayStatus, State, TrackRef}, + user_attributes::UserAttributesMutation, + }, +}; + +#[derive(Debug, Error)] +pub enum SpircError { + #[error("response payload empty")] + NoData, + #[error("message addressed at another ident: {0}")] + Ident(String), + #[error("message pushed for another URI")] + InvalidUri(String), +} + +impl From for Error { + fn from(err: SpircError) -> Self { + match err { + SpircError::NoData => Error::unavailable(err), + SpircError::Ident(_) => Error::aborted(err), + SpircError::InvalidUri(_) => Error::aborted(err), + } + } +} + +#[derive(Debug)] enum SpircPlayStatus { Stopped, LoadingPlay { @@ -46,7 +85,6 @@ type BoxedStream = Pin + Send>>; struct SpircTask { player: Player, mixer: Box, - config: SpircTaskConfig, sequence: SeqGenerator, @@ -56,18 +94,25 @@ struct SpircTask { play_request_id: Option, play_status: SpircPlayStatus, - subscription: BoxedStream, + remote_update: BoxedStream>, + connection_id_update: BoxedStream>, + user_attributes_update: BoxedStream>, + user_attributes_mutation: BoxedStream>, sender: MercurySender, commands: Option>, player_events: Option, shutdown: bool, session: Session, - context_fut: BoxedFuture>, - autoplay_fut: BoxedFuture>, + context_fut: BoxedFuture>, + autoplay_fut: BoxedFuture>, context: Option, + + spirc_id: usize, } +static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0); + pub enum SpircCommand { Play, PlayPause, @@ -80,10 +125,6 @@ pub enum SpircCommand { Shuffle, } -struct SpircTaskConfig { - autoplay: bool, -} - const CONTEXT_TRACKS_HISTORY: usize = 10; const CONTEXT_FETCH_THRESHOLD: u32 = 5; @@ -107,7 +148,7 @@ fn initial_state() -> State { fn initial_device_state(config: ConnectConfig) -> DeviceState { { let mut msg = DeviceState::new(); - msg.set_sw_version(version::VERSION_STRING.to_string()); + msg.set_sw_version(version::SEMVER.to_string()); msg.set_is_active(false); msg.set_can_play(true); msg.set_volume(0); @@ -220,41 +261,87 @@ fn url_encode(bytes: impl AsRef<[u8]>) -> String { } impl Spirc { - pub fn new( + pub async fn new( config: ConnectConfig, session: Session, + credentials: Credentials, player: Player, mixer: Box, - ) -> (Spirc, impl Future) { - debug!("new Spirc[{}]", session.session_id()); + ) -> Result<(Spirc, impl Future), Error> { + let spirc_id = SPIRC_COUNTER.fetch_add(1, Ordering::AcqRel); + debug!("new Spirc[{}]", spirc_id); let ident = session.device_id().to_owned(); - // Uri updated in response to issue #288 - debug!("canonical_username: {}", &session.username()); - let uri = format!("hm://remote/user/{}/", url_encode(&session.username())); - - let subscription = Box::pin( + let remote_update = Box::pin( session .mercury() - .subscribe(uri.clone()) - .map(Result::unwrap) + .listen_for("hm://remote/user/") .map(UnboundedReceiverStream::new) .flatten_stream() - .map(|response| -> Frame { - let data = response.payload.first().unwrap(); - Frame::parse_from_bytes(data).unwrap() + .map(|response| -> Result<(String, Frame), Error> { + let uri_split: Vec<&str> = response.uri.split('/').collect(); + let username = match uri_split.get(4) { + Some(s) => s.to_string(), + None => String::new(), + }; + + let data = response.payload.first().ok_or(SpircError::NoData)?; + Ok((username, Frame::parse_from_bytes(data)?)) }), ); - let sender = session.mercury().sender(uri); + let connection_id_update = Box::pin( + session + .mercury() + .listen_for("hm://pusher/v1/connections/") + .map(UnboundedReceiverStream::new) + .flatten_stream() + .map(|response| -> Result { + let connection_id = response + .uri + .strip_prefix("hm://pusher/v1/connections/") + .ok_or_else(|| SpircError::InvalidUri(response.uri.clone()))?; + Ok(connection_id.to_owned()) + }), + ); + + let user_attributes_update = Box::pin( + session + .mercury() + .listen_for("spotify:user:attributes:update") + .map(UnboundedReceiverStream::new) + .flatten_stream() + .map(|response| -> Result { + let data = response.payload.first().ok_or(SpircError::NoData)?; + Ok(UserAttributesUpdate::parse_from_bytes(data)?) + }), + ); + + let user_attributes_mutation = Box::pin( + session + .mercury() + .listen_for("spotify:user:attributes:mutated") + .map(UnboundedReceiverStream::new) + .flatten_stream() + .map(|response| -> Result { + let data = response.payload.first().ok_or(SpircError::NoData)?; + Ok(UserAttributesMutation::parse_from_bytes(data)?) + }), + ); + + // Connect *after* all message listeners are registered + session.connect(credentials, true).await?; + + let canonical_username = &session.username(); + debug!("canonical_username: {}", canonical_username); + let sender_uri = format!("hm://remote/user/{}/", url_encode(canonical_username)); + + let sender = session.mercury().sender(sender_uri); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let initial_volume = config.initial_volume; - let task_config = SpircTaskConfig { - autoplay: config.autoplay, - }; let device = initial_device_state(config); @@ -263,7 +350,6 @@ impl Spirc { let mut task = SpircTask { player, mixer, - config: task_config, sequence: SeqGenerator::new(1), @@ -274,7 +360,10 @@ impl Spirc { play_request_id: None, play_status: SpircPlayStatus::Stopped, - subscription, + remote_update, + connection_id_update, + user_attributes_update, + user_attributes_mutation, sender, commands: Some(cmd_rx), player_events: Some(player_events), @@ -285,6 +374,8 @@ impl Spirc { context_fut: Box::pin(future::pending()), autoplay_fut: Box::pin(future::pending()), context: None, + + spirc_id, }; if let Some(volume) = initial_volume { @@ -296,37 +387,37 @@ impl Spirc { let spirc = Spirc { commands: cmd_tx }; - task.hello(); + task.hello()?; - (spirc, task.run()) + Ok((spirc, task.run())) } - pub fn play(&self) { - let _ = self.commands.send(SpircCommand::Play); + pub fn play(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Play)?) } - pub fn play_pause(&self) { - let _ = self.commands.send(SpircCommand::PlayPause); + pub fn play_pause(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::PlayPause)?) } - pub fn pause(&self) { - let _ = self.commands.send(SpircCommand::Pause); + pub fn pause(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Pause)?) } - pub fn prev(&self) { - let _ = self.commands.send(SpircCommand::Prev); + pub fn prev(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Prev)?) } - pub fn next(&self) { - let _ = self.commands.send(SpircCommand::Next); + pub fn next(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Next)?) } - pub fn volume_up(&self) { - let _ = self.commands.send(SpircCommand::VolumeUp); + pub fn volume_up(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::VolumeUp)?) } - pub fn volume_down(&self) { - let _ = self.commands.send(SpircCommand::VolumeDown); + pub fn volume_down(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::VolumeDown)?) } - pub fn shutdown(&self) { - let _ = self.commands.send(SpircCommand::Shutdown); + pub fn shutdown(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Shutdown)?) } - pub fn shuffle(&self) { - let _ = self.commands.send(SpircCommand::Shuffle); + pub fn shuffle(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Shuffle)?) } } @@ -336,18 +427,61 @@ impl SpircTask { let commands = self.commands.as_mut(); let player_events = self.player_events.as_mut(); tokio::select! { - frame = self.subscription.next() => match frame { - Some(frame) => self.handle_frame(frame), + remote_update = self.remote_update.next() => match remote_update { + Some(result) => match result { + Ok((username, frame)) => { + if username != self.session.username() { + error!("could not dispatch remote update: frame was intended for {}", username); + } else if let Err(e) = self.handle_remote_update(frame) { + error!("could not dispatch remote update: {}", e); + } + }, + Err(e) => error!("could not parse remote update: {}", e), + } None => { - error!("subscription terminated"); + error!("remote update selected, but none received"); break; } }, - cmd = async { commands.unwrap().recv().await }, if commands.is_some() => if let Some(cmd) = cmd { - self.handle_command(cmd); + user_attributes_update = self.user_attributes_update.next() => match user_attributes_update { + Some(result) => match result { + Ok(attributes) => self.handle_user_attributes_update(attributes), + Err(e) => error!("could not parse user attributes update: {}", e), + } + None => { + error!("user attributes update selected, but none received"); + break; + } }, - event = async { player_events.unwrap().recv().await }, if player_events.is_some() => if let Some(event) = event { - self.handle_player_event(event) + user_attributes_mutation = self.user_attributes_mutation.next() => match user_attributes_mutation { + Some(result) => match result { + Ok(attributes) => self.handle_user_attributes_mutation(attributes), + Err(e) => error!("could not parse user attributes mutation: {}", e), + } + None => { + error!("user attributes mutation selected, but none received"); + break; + } + }, + connection_id_update = self.connection_id_update.next() => match connection_id_update { + Some(result) => match result { + Ok(connection_id) => self.handle_connection_id_update(connection_id), + Err(e) => error!("could not parse connection ID update: {}", e), + } + None => { + error!("connection ID update selected, but none received"); + break; + } + }, + cmd = async { commands?.recv().await }, if commands.is_some() => if let Some(cmd) = cmd { + if let Err(e) = self.handle_command(cmd) { + error!("could not dispatch command: {}", e); + } + }, + event = async { player_events?.recv().await }, if player_events.is_some() => if let Some(event) = event { + if let Err(e) = self.handle_player_event(event) { + error!("could not dispatch player event: {}", e); + } }, result = self.sender.flush(), if !self.sender.is_flushed() => if result.is_err() { error!("Cannot flush spirc event sender."); @@ -398,7 +532,7 @@ impl SpircTask { } if self.sender.flush().await.is_err() { - warn!("Cannot flush spirc event sender."); + warn!("Cannot flush spirc event sender when done."); } } @@ -417,80 +551,81 @@ impl SpircTask { self.state.set_position_ms(position_ms); } - fn handle_command(&mut self, cmd: SpircCommand) { + fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> { let active = self.device.get_is_active(); match cmd { SpircCommand::Play => { if active { self.handle_play(); - self.notify(None, true); + self.notify(None) } else { - CommandSender::new(self, MessageType::kMessageTypePlay).send(); + CommandSender::new(self, MessageType::kMessageTypePlay).send() } } SpircCommand::PlayPause => { if active { self.handle_play_pause(); - self.notify(None, true); + self.notify(None) } else { - CommandSender::new(self, MessageType::kMessageTypePlayPause).send(); + CommandSender::new(self, MessageType::kMessageTypePlayPause).send() } } SpircCommand::Pause => { if active { self.handle_pause(); - self.notify(None, true); + self.notify(None) } else { - CommandSender::new(self, MessageType::kMessageTypePause).send(); + CommandSender::new(self, MessageType::kMessageTypePause).send() } } SpircCommand::Prev => { if active { self.handle_prev(); - self.notify(None, true); + self.notify(None) } else { - CommandSender::new(self, MessageType::kMessageTypePrev).send(); + CommandSender::new(self, MessageType::kMessageTypePrev).send() } } SpircCommand::Next => { if active { self.handle_next(); - self.notify(None, true); + self.notify(None) } else { - CommandSender::new(self, MessageType::kMessageTypeNext).send(); + CommandSender::new(self, MessageType::kMessageTypeNext).send() } } SpircCommand::VolumeUp => { if active { self.handle_volume_up(); - self.notify(None, true); + self.notify(None) } else { - CommandSender::new(self, MessageType::kMessageTypeVolumeUp).send(); + CommandSender::new(self, MessageType::kMessageTypeVolumeUp).send() } } SpircCommand::VolumeDown => { if active { self.handle_volume_down(); - self.notify(None, true); + self.notify(None) } else { - CommandSender::new(self, MessageType::kMessageTypeVolumeDown).send(); + CommandSender::new(self, MessageType::kMessageTypeVolumeDown).send() } } SpircCommand::Shutdown => { - CommandSender::new(self, MessageType::kMessageTypeGoodbye).send(); + CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?; self.player.stop(); self.shutdown = true; if let Some(rx) = self.commands.as_mut() { rx.close() } + Ok(()) } SpircCommand::Shuffle => { - CommandSender::new(self, MessageType::kMessageTypeShuffle).send(); + CommandSender::new(self, MessageType::kMessageTypeShuffle).send() } } } - fn handle_player_event(&mut self, event: PlayerEvent) { + fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { // we only process events if the play_request_id matches. If it doesn't, it is // an event that belongs to a previous track and only arrives now due to a race // condition. In this case we have updated the state already and don't want to @@ -499,8 +634,13 @@ impl SpircTask { if Some(play_request_id) == self.play_request_id { match event { PlayerEvent::EndOfTrack { .. } => self.handle_end_of_track(), - PlayerEvent::Loading { .. } => self.notify(None, false), + PlayerEvent::Loading { .. } => { + trace!("==> kPlayStatusLoading"); + self.state.set_status(PlayStatus::kPlayStatusLoading); + self.notify(None) + } PlayerEvent::Playing { position_ms, .. } => { + trace!("==> kPlayStatusPlay"); let new_nominal_start_time = self.now_ms() - position_ms as i64; match self.play_status { SpircPlayStatus::Playing { @@ -510,27 +650,29 @@ impl SpircTask { if (*nominal_start_time - new_nominal_start_time).abs() > 100 { *nominal_start_time = new_nominal_start_time; self.update_state_position(position_ms); - self.notify(None, true); + self.notify(None) + } else { + Ok(()) } } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { self.state.set_status(PlayStatus::kPlayStatusPlay); self.update_state_position(position_ms); - self.notify(None, true); self.play_status = SpircPlayStatus::Playing { nominal_start_time: new_nominal_start_time, preloading_of_next_track_triggered: false, }; + self.notify(None) } - _ => (), - }; - trace!("==> kPlayStatusPlay"); + _ => Ok(()), + } } PlayerEvent::Paused { position_ms: new_position_ms, .. } => { + trace!("==> kPlayStatusPause"); match self.play_status { SpircPlayStatus::Paused { ref mut position_ms, @@ -539,68 +681,119 @@ impl SpircTask { if *position_ms != new_position_ms { *position_ms = new_position_ms; self.update_state_position(new_position_ms); - self.notify(None, true); + self.notify(None) + } else { + Ok(()) } } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { self.state.set_status(PlayStatus::kPlayStatusPause); self.update_state_position(new_position_ms); - self.notify(None, true); self.play_status = SpircPlayStatus::Paused { position_ms: new_position_ms, preloading_of_next_track_triggered: false, }; + self.notify(None) } - _ => (), + _ => Ok(()), } - trace!("==> kPlayStatusPause"); } - PlayerEvent::Stopped { .. } => match self.play_status { - SpircPlayStatus::Stopped => (), - _ => { - warn!("The player has stopped unexpectedly."); - self.state.set_status(PlayStatus::kPlayStatusStop); - self.notify(None, true); - self.play_status = SpircPlayStatus::Stopped; + PlayerEvent::Stopped { .. } => { + trace!("==> kPlayStatusStop"); + match self.play_status { + SpircPlayStatus::Stopped => Ok(()), + _ => { + self.state.set_status(PlayStatus::kPlayStatusStop); + self.play_status = SpircPlayStatus::Stopped; + self.notify(None) + } } - }, - PlayerEvent::TimeToPreloadNextTrack { .. } => self.handle_preload_next_track(), - PlayerEvent::Unavailable { track_id, .. } => self.handle_unavailable(track_id), - _ => (), + } + PlayerEvent::TimeToPreloadNextTrack { .. } => { + self.handle_preload_next_track(); + Ok(()) + } + PlayerEvent::Unavailable { track_id, .. } => { + self.handle_unavailable(track_id); + Ok(()) + } + _ => Ok(()), } + } else { + Ok(()) + } + } else { + Ok(()) + } + } + + fn handle_connection_id_update(&mut self, connection_id: String) { + trace!("Received connection ID update: {:?}", connection_id); + self.session.set_connection_id(&connection_id); + } + + fn handle_user_attributes_update(&mut self, update: UserAttributesUpdate) { + trace!("Received attributes update: {:#?}", update); + let attributes: UserAttributes = update + .get_pairs() + .iter() + .map(|pair| (pair.get_key().to_owned(), pair.get_value().to_owned())) + .collect(); + self.session.set_user_attributes(attributes) + } + + fn handle_user_attributes_mutation(&mut self, mutation: UserAttributesMutation) { + for attribute in mutation.get_fields().iter() { + let key = attribute.get_name(); + if let Some(old_value) = self.session.user_data().attributes.get(key) { + let new_value = match old_value.as_ref() { + "0" => "1", + "1" => "0", + _ => old_value, + }; + self.session.set_user_attribute(key, new_value); + + trace!( + "Received attribute mutation, {} was {} is now {}", + key, + old_value, + new_value + ); + + if key == "filter-explicit-content" && new_value == "1" { + self.player.skip_explicit_content(); + } + } else { + trace!( + "Received attribute mutation for {} but key was not found!", + key + ); } } } - fn handle_frame(&mut self, frame: Frame) { - let state_string = match frame.get_state().get_status() { - PlayStatus::kPlayStatusLoading => "kPlayStatusLoading", - PlayStatus::kPlayStatusPause => "kPlayStatusPause", - PlayStatus::kPlayStatusStop => "kPlayStatusStop", - PlayStatus::kPlayStatusPlay => "kPlayStatusPlay", - }; + fn handle_remote_update(&mut self, update: Frame) -> Result<(), Error> { + trace!("Received update frame: {:#?}", update); - debug!( - "{:?} {:?} {} {} {} {}", - frame.get_typ(), - frame.get_device_state().get_name(), - frame.get_ident(), - frame.get_seq_nr(), - frame.get_state_update_id(), - state_string, - ); - - if frame.get_ident() == self.ident - || (!frame.get_recipient().is_empty() && !frame.get_recipient().contains(&self.ident)) + // First see if this update was intended for us. + let device_id = &self.ident; + let ident = update.get_ident(); + if ident == device_id + || (!update.get_recipient().is_empty() && !update.get_recipient().contains(device_id)) { - return; + return Err(SpircError::Ident(ident.to_string()).into()); } - match frame.get_typ() { - MessageType::kMessageTypeHello => { - self.notify(Some(frame.get_ident()), true); + for entry in update.get_device_state().get_metadata().iter() { + if entry.get_field_type() == "client_id" { + self.session.set_client_id(entry.get_metadata()); + break; } + } + + match update.get_typ() { + MessageType::kMessageTypeHello => self.notify(Some(ident)), MessageType::kMessageTypeLoad => { if !self.device.get_is_active() { @@ -609,64 +802,62 @@ impl SpircTask { self.device.set_became_active_at(now); } - self.update_tracks(&frame); + self.update_tracks(&update); if !self.state.get_track().is_empty() { let start_playing = - frame.get_state().get_status() == PlayStatus::kPlayStatusPlay; - self.load_track(start_playing, frame.get_state().get_position_ms()); + update.get_state().get_status() == PlayStatus::kPlayStatusPlay; + self.load_track(start_playing, update.get_state().get_position_ms()); } else { info!("No more tracks left in queue"); - self.state.set_status(PlayStatus::kPlayStatusStop); - self.player.stop(); - self.play_status = SpircPlayStatus::Stopped; + self.handle_stop(); } - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypePlay => { self.handle_play(); - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypePlayPause => { self.handle_play_pause(); - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypePause => { self.handle_pause(); - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypeNext => { self.handle_next(); - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypePrev => { self.handle_prev(); - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypeVolumeUp => { self.handle_volume_up(); - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypeVolumeDown => { self.handle_volume_down(); - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypeRepeat => { - self.state.set_repeat(frame.get_state().get_repeat()); - self.notify(None, true); + self.state.set_repeat(update.get_state().get_repeat()); + self.notify(None) } MessageType::kMessageTypeShuffle => { - self.state.set_shuffle(frame.get_state().get_shuffle()); + self.state.set_shuffle(update.get_state().get_shuffle()); if self.state.get_shuffle() { let current_index = self.state.get_playing_track_index(); let tracks = self.state.mut_track(); @@ -682,17 +873,16 @@ impl SpircTask { let context = self.state.get_context_uri(); debug!("{:?}", context); } - self.notify(None, true); + self.notify(None) } MessageType::kMessageTypeSeek => { - self.handle_seek(frame.get_position()); - self.notify(None, true); + self.handle_seek(update.get_position()); + self.notify(None) } MessageType::kMessageTypeReplace => { - self.update_tracks(&frame); - self.notify(None, true); + self.update_tracks(&update); if let SpircPlayStatus::Playing { preloading_of_next_track_triggered, @@ -710,41 +900,41 @@ impl SpircTask { } } } + + self.notify(None) } MessageType::kMessageTypeVolume => { - self.set_volume(frame.get_volume() as u16); - self.notify(None, true); + self.set_volume(update.get_volume() as u16); + self.notify(None) } MessageType::kMessageTypeNotify => { if self.device.get_is_active() - && frame.get_device_state().get_is_active() + && update.get_device_state().get_is_active() && self.device.get_became_active_at() - <= frame.get_device_state().get_became_active_at() + <= update.get_device_state().get_became_active_at() { self.device.set_is_active(false); - self.state.set_status(PlayStatus::kPlayStatusStop); - self.player.stop(); - self.play_status = SpircPlayStatus::Stopped; + self.handle_stop(); } + Ok(()) } - _ => (), + _ => Ok(()), } } + fn handle_stop(&mut self) { + self.player.stop(); + } + fn handle_play(&mut self) { match self.play_status { SpircPlayStatus::Paused { position_ms, preloading_of_next_track_triggered, } => { - // Synchronize the volume from the mixer. This is useful on - // systems that can switch sources from and back to librespot. - let current_volume = self.mixer.volume(); - self.set_volume(current_volume); - self.player.play(); self.state.set_status(PlayStatus::kPlayStatusPlay); self.update_state_position(position_ms); @@ -757,8 +947,13 @@ impl SpircTask { self.player.play(); self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; } - _ => (), + _ => return, } + + // Synchronize the volume from the mixer. This is useful on + // systems that can switch sources from and back to librespot. + let current_volume = self.mixer.volume(); + self.set_volume(current_volume); } fn handle_play_pause(&mut self) { @@ -850,13 +1045,14 @@ impl SpircTask { .. } => { *preloading_of_next_track_triggered = true; - if let Some(track_id) = self.preview_next_track() { - self.player.preload(track_id); - } } - SpircPlayStatus::LoadingPause { .. } - | SpircPlayStatus::LoadingPlay { .. } - | SpircPlayStatus::Stopped => (), + _ => (), + } + + if let Some(track_id) = self.preview_next_track() { + self.player.preload(track_id); + } else { + self.handle_stop(); } } @@ -864,7 +1060,6 @@ impl SpircTask { fn handle_unavailable(&mut self, track_id: SpotifyId) { let unavailables = self.get_track_index_for_spotify_id(&track_id, 0); for &index in unavailables.iter() { - debug_assert_eq!(self.state.get_track()[index].get_gid(), track_id.to_raw()); let mut unplayable_track_ref = TrackRef::new(); unplayable_track_ref.set_gid(self.state.get_track()[index].get_gid().to_vec()); // Misuse context field to flag the track @@ -903,8 +1098,19 @@ impl SpircTask { self.context_fut = self.resolve_station(&context_uri); self.update_tracks_from_context(); } + if new_index >= tracks_len { - if self.config.autoplay { + let autoplay = self + .session + .get_user_attribute("autoplay") + .unwrap_or_else(|| { + warn!( + "Unable to get autoplay user attribute. Continuing with autoplay disabled." + ); + "0".into() + }); + + if autoplay == "1" { // Extend the playlist debug!("Extending playlist <{}>", context_uri); self.update_tracks_from_context(); @@ -925,9 +1131,7 @@ impl SpircTask { } else { info!("Not playing next track because there are no more tracks left in queue."); self.state.set_playing_track_index(0); - self.state.set_status(PlayStatus::kPlayStatusStop); - self.player.stop(); - self.play_status = SpircPlayStatus::Stopped; + self.handle_stop(); } } @@ -980,9 +1184,9 @@ impl SpircTask { self.set_volume(volume); } - fn handle_end_of_track(&mut self) { + fn handle_end_of_track(&mut self) -> Result<(), Error> { self.handle_next(); - self.notify(None, true); + self.notify(None) } fn position(&mut self) -> u32 { @@ -997,48 +1201,40 @@ impl SpircTask { } } - fn resolve_station(&self, uri: &str) -> BoxedFuture> { + fn resolve_station(&self, uri: &str) -> BoxedFuture> { let radio_uri = format!("hm://radio-apollo/v3/stations/{}", uri); self.resolve_uri(&radio_uri) } - fn resolve_autoplay_uri(&self, uri: &str) -> BoxedFuture> { + fn resolve_autoplay_uri(&self, uri: &str) -> BoxedFuture> { let query_uri = format!("hm://autoplay-enabled/query?uri={}", uri); let request = self.session.mercury().get(query_uri); Box::pin( async { - let response = request.await?; + let response = request?.await?; if response.status_code == 200 { - let data = response - .payload - .first() - .expect("Empty autoplay uri") - .to_vec(); - let autoplay_uri = String::from_utf8(data).unwrap(); - Ok(autoplay_uri) + let data = response.payload.first().ok_or(SpircError::NoData)?.to_vec(); + Ok(String::from_utf8(data)?) } else { warn!("No autoplay_uri found"); - Err(MercuryError) + Err(MercuryError::Response(response).into()) } } .fuse(), ) } - fn resolve_uri(&self, uri: &str) -> BoxedFuture> { + fn resolve_uri(&self, uri: &str) -> BoxedFuture> { let request = self.session.mercury().get(uri); Box::pin( async move { - let response = request.await?; + let response = request?.await?; - let data = response - .payload - .first() - .expect("Empty payload on context uri"); - let response: serde_json::Value = serde_json::from_slice(data).unwrap(); + let data = response.payload.first().ok_or(SpircError::NoData)?; + let response: serde_json::Value = serde_json::from_slice(data)?; Ok(response) } @@ -1074,19 +1270,24 @@ impl SpircTask { } fn update_tracks(&mut self, frame: &protocol::spirc::Frame) { - debug!("State: {:?}", frame.get_state()); + trace!("State: {:#?}", frame.get_state()); + let index = frame.get_state().get_playing_track_index(); let context_uri = frame.get_state().get_context_uri().to_owned(); let tracks = frame.get_state().get_track(); - debug!("Frame has {:?} tracks", tracks.len()); + + trace!("Frame has {:?} tracks", tracks.len()); + if context_uri.starts_with("spotify:station:") || context_uri.starts_with("spotify:dailymix:") { self.context_fut = self.resolve_station(&context_uri); - } else if self.config.autoplay { - info!("Fetching autoplay context uri"); - // Get autoplay_station_uri for regular playlists - self.autoplay_fut = self.resolve_autoplay_uri(&context_uri); + } else if let Some(autoplay) = self.session.get_user_attribute("autoplay") { + if &autoplay == "1" { + info!("Fetching autoplay context uri"); + // Get autoplay_station_uri for regular playlists + self.autoplay_fut = self.resolve_autoplay_uri(&context_uri); + } } self.player @@ -1107,15 +1308,6 @@ impl SpircTask { } } - // should this be a method of SpotifyId directly? - fn get_spotify_id_for_track(&self, track_ref: &TrackRef) -> Result { - SpotifyId::from_raw(track_ref.get_gid()).or_else(|_| { - let uri = track_ref.get_uri(); - debug!("Malformed or no gid, attempting to parse URI <{}>", uri); - SpotifyId::from_uri(uri) - }) - } - // Helper to find corresponding index(s) for track_id fn get_track_index_for_spotify_id( &self, @@ -1128,8 +1320,6 @@ impl SpircTask { .filter(|&(_, track_ref)| track_ref.get_gid() == track_id.to_raw()) .map(|(idx, _)| start_index + idx) .collect(); - // Sanity check - debug_assert!(!index.is_empty()); index } @@ -1162,11 +1352,8 @@ impl SpircTask { // E.g - context based frames sometimes contain tracks with let mut track_ref = self.state.get_track()[new_playlist_index].clone(); - let mut track_id = self.get_spotify_id_for_track(&track_ref); - while self.track_ref_is_unavailable(&track_ref) - || track_id.is_err() - || track_id.unwrap().audio_type == SpotifyAudioType::NonPlayable - { + let mut track_id = SpotifyId::try_from(&track_ref); + while self.track_ref_is_unavailable(&track_ref) || track_id.is_err() { warn!( "Skipping track <{:?}> at position [{}] of {}", track_ref, new_playlist_index, tracks_len @@ -1182,7 +1369,7 @@ impl SpircTask { return None; } track_ref = self.state.get_track()[new_playlist_index].clone(); - track_id = self.get_spotify_id_for_track(&track_ref); + track_id = SpotifyId::try_from(&track_ref); } match track_id { @@ -1210,21 +1397,16 @@ impl SpircTask { } } None => { - self.state.set_status(PlayStatus::kPlayStatusStop); - self.player.stop(); - self.play_status = SpircPlayStatus::Stopped; + self.handle_stop(); } } } - fn hello(&mut self) { - CommandSender::new(self, MessageType::kMessageTypeHello).send(); + fn hello(&mut self) -> Result<(), Error> { + CommandSender::new(self, MessageType::kMessageTypeHello).send() } - fn notify(&mut self, recipient: Option<&str>, suppress_loading_status: bool) { - if suppress_loading_status && (self.state.get_status() == PlayStatus::kPlayStatusLoading) { - return; - }; + fn notify(&mut self, recipient: Option<&str>) -> Result<(), Error> { let status_string = match self.state.get_status() { PlayStatus::kPlayStatusLoading => "kPlayStatusLoading", PlayStatus::kPlayStatusPause => "kPlayStatusPause", @@ -1236,7 +1418,7 @@ impl SpircTask { if let Some(s) = recipient { cs = cs.recipient(s); } - cs.send(); + cs.send() } fn set_volume(&mut self, volume: u16) { @@ -1251,7 +1433,7 @@ impl SpircTask { impl Drop for SpircTask { fn drop(&mut self) { - debug!("drop Spirc[{}]", self.session.session_id()); + debug!("drop Spirc[{}]", self.spirc_id); } } @@ -1284,11 +1466,11 @@ impl<'a> CommandSender<'a> { self } - fn send(mut self) { + fn send(mut self) -> Result<(), Error> { if !self.frame.has_state() && self.spirc.device.get_is_active() { self.frame.set_state(self.spirc.state.clone()); } - self.spirc.sender.send(self.frame.write_to_bytes().unwrap()); + self.spirc.sender.send(self.frame.write_to_bytes()?) } } diff --git a/core/Cargo.toml b/core/Cargo.toml index 6f8b89eb..5a7dd88d 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -13,42 +13,54 @@ path = "../protocol" version = "0.4.2" [dependencies] -aes = "0.6" +aes = "0.8" base64 = "0.13" byteorder = "1.4" -bytes = "1.0" +bytes = "1" +dns-sd = { version = "0.1", optional = true } form_urlencoded = "1.0" -futures-core = { version = "0.3", default-features = false } -futures-util = { version = "0.3", default-features = false, features = ["alloc", "bilock", "unstable", "sink"] } -hmac = "0.11" -httparse = "1.3" +futures-core = "0.3" +futures-util = { version = "0.3", features = ["alloc", "bilock", "sink", "unstable"] } +hmac = "0.12" +httparse = "1.7" http = "0.2" -hyper = { version = "0.14", features = ["client", "tcp", "http1"] } -hyper-proxy = { version = "0.9.1", default-features = false } +hyper = { version = "0.14", features = ["client", "http1", "http2", "tcp"] } +hyper-proxy = { version = "0.9", default-features = false, features = ["rustls"] } +hyper-rustls = { version = "0.23", features = ["http2"] } log = "0.4" +num = "0.4" num-bigint = { version = "0.4", features = ["rand"] } +num-derive = "0.3" num-integer = "0.1" num-traits = "0.2" -once_cell = "1.5.2" -pbkdf2 = { version = "0.8", default-features = false, features = ["hmac"] } +once_cell = "1" +parking_lot = { version = "0.12", features = ["deadlock_detection"] } +pbkdf2 = { version = "0.11", default-features = false, features = ["hmac"] } priority-queue = "1.2" -protobuf = "2.14.0" +protobuf = "2" +quick-xml = { version = "0.23", features = ["serialize"] } rand = "0.8" +rsa = "0.6" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -sha-1 = "0.9" -shannon = "0.2.0" -thiserror = "1.0.7" -tokio = { version = "1.0", features = ["io-util", "net", "rt", "sync"] } -tokio-stream = "0.1.1" +sha1 = "0.10" +shannon = "0.2" +thiserror = "1.0" +time = "0.3" +tokio = { version = "1", features = ["io-util", "macros", "net", "parking_lot", "rt", "sync", "time"] } +tokio-stream = "0.1" +tokio-tungstenite = { version = "*", default-features = false, features = ["rustls-tls-native-roots"] } tokio-util = { version = "0.7", features = ["codec"] } -url = "2.1" -uuid = { version = "1.0", default-features = false, features = ["v4"] } +url = "2" +uuid = { version = "1", default-features = false, features = ["v4"] } [build-dependencies] rand = "0.8" -vergen = "3.0.4" +vergen = { version = "7", default-features = false, features = ["build", "git"] } [dev-dependencies] env_logger = "0.9" -tokio = {version = "1.0", features = ["macros"] } +tokio = { version = "1", features = ["macros", "parking_lot"] } + +[features] +with-dns-sd = ["dns-sd"] diff --git a/core/build.rs b/core/build.rs index e4aa1187..784f7c2f 100644 --- a/core/build.rs +++ b/core/build.rs @@ -1,14 +1,19 @@ -use rand::distributions::Alphanumeric; -use rand::Rng; -use std::env; -use vergen::{generate_cargo_keys, ConstantsFlags}; +use rand::{distributions::Alphanumeric, Rng}; +use vergen::{vergen, Config, ShaKind, TimestampKind}; fn main() { - let mut flags = ConstantsFlags::all(); - flags.toggle(ConstantsFlags::REBUILD_ON_HEAD_CHANGE); - generate_cargo_keys(ConstantsFlags::all()).expect("Unable to generate the cargo keys!"); + let mut config = Config::default(); + *config.build_mut().kind_mut() = TimestampKind::DateOnly; + *config.git_mut().enabled_mut() = true; + *config.git_mut().commit_timestamp_mut() = true; + *config.git_mut().commit_timestamp_kind_mut() = TimestampKind::DateOnly; + *config.git_mut().sha_mut() = true; + *config.git_mut().sha_kind_mut() = ShaKind::Short; + *config.git_mut().rerun_on_head_change_mut() = true; - let build_id = match env::var("SOURCE_DATE_EPOCH") { + vergen(config).expect("Unable to generate the cargo keys!"); + + let build_id = match std::env::var("SOURCE_DATE_EPOCH") { Ok(val) => val, Err(_) => rand::thread_rng() .sample_iter(Alphanumeric) diff --git a/core/src/apresolve.rs b/core/src/apresolve.rs index dc3a5cce..72b089dd 100644 --- a/core/src/apresolve.rs +++ b/core/src/apresolve.rs @@ -1,108 +1,131 @@ -use std::error::Error; - -use hyper::client::HttpConnector; -use hyper::{Body, Client, Method, Request, Uri}; -use hyper_proxy::{Intercept, Proxy, ProxyConnector}; +use hyper::{Body, Method, Request}; use serde::Deserialize; -use url::Url; -const APRESOLVE_ENDPOINT: &str = "http://apresolve.spotify.com:80"; -const AP_FALLBACK: &str = "ap.spotify.com:443"; -const AP_BLACKLIST: [&str; 2] = ["ap-gew4.spotify.com", "ap-gue1.spotify.com"]; +use crate::Error; -#[derive(Clone, Debug, Deserialize)] -struct ApResolveData { - ap_list: Vec, +pub type SocketAddress = (String, u16); + +#[derive(Default)] +pub struct AccessPoints { + accesspoint: Vec, + dealer: Vec, + spclient: Vec, } -async fn try_apresolve( - proxy: Option<&Url>, - ap_port: Option, -) -> Result> { - let port = ap_port.unwrap_or(443); +#[derive(Deserialize)] +pub struct ApResolveData { + accesspoint: Vec, + dealer: Vec, + spclient: Vec, +} - let mut req = Request::new(Body::empty()); - *req.method_mut() = Method::GET; - // panic safety: APRESOLVE_ENDPOINT above is valid url. - *req.uri_mut() = APRESOLVE_ENDPOINT.parse().expect("invalid AP resolve URL"); +// These addresses probably do some geo-location based traffic management or at least DNS-based +// load balancing. They are known to fail when the normal resolvers are up, so that's why they +// should only be used as fallback. +impl Default for ApResolveData { + fn default() -> Self { + Self { + accesspoint: vec![String::from("ap.spotify.com:443")], + dealer: vec![String::from("dealer.spotify.com:443")], + spclient: vec![String::from("spclient.wg.spotify.com:443")], + } + } +} - let response = if let Some(url) = proxy { - // Panic safety: all URLs are valid URIs - let uri = url.to_string().parse().unwrap(); - let proxy = Proxy::new(Intercept::All, uri); - let connector = HttpConnector::new(); - let proxy_connector = ProxyConnector::from_proxy_unsecured(connector, proxy); - Client::builder() - .build(proxy_connector) - .request(req) - .await? - } else { - Client::new().request(req).await? - }; +component! { + ApResolver : ApResolverInner { + data: AccessPoints = AccessPoints::default(), + } +} - let body = hyper::body::to_bytes(response.into_body()).await?; - let data: ApResolveData = serde_json::from_slice(body.as_ref())?; +impl ApResolver { + // return a port if a proxy URL and/or a proxy port was specified. This is useful even when + // there is no proxy, but firewalls only allow certain ports (e.g. 443 and not 4070). + pub fn port_config(&self) -> Option { + if self.session().config().proxy.is_some() || self.session().config().ap_port.is_some() { + Some(self.session().config().ap_port.unwrap_or(443)) + } else { + None + } + } - // filter APs that are known to cause channel errors - let aps: Vec = data - .ap_list - .into_iter() - .filter_map(|ap| { - let host = ap.parse::().ok()?.host()?.to_owned(); - if !AP_BLACKLIST.iter().any(|&blacklisted| host == blacklisted) { - Some(ap) - } else { - warn!("Ignoring blacklisted access point {}", ap); - None - } + fn process_data(&self, data: Vec) -> Vec { + data.into_iter() + .filter_map(|ap| { + let mut split = ap.rsplitn(2, ':'); + let port = split.next()?; + let host = split.next()?.to_owned(); + let port: u16 = port.parse().ok()?; + if let Some(p) = self.port_config() { + if p != port { + return None; + } + } + Some((host, port)) + }) + .collect() + } + + pub async fn try_apresolve(&self) -> Result { + let req = Request::builder() + .method(Method::GET) + .uri("http://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient") + .body(Body::empty())?; + + let body = self.session().http_client().request_body(req).await?; + let data: ApResolveData = serde_json::from_slice(body.as_ref())?; + + Ok(data) + } + + async fn apresolve(&self) { + let result = self.try_apresolve().await; + + self.lock(|inner| { + let data = match result { + Ok(data) => data, + Err(e) => { + warn!("Failed to resolve access points, using fallbacks: {}", e); + ApResolveData::default() + } + }; + + inner.data.accesspoint = self.process_data(data.accesspoint); + inner.data.dealer = self.process_data(data.dealer); + inner.data.spclient = self.process_data(data.spclient); }) - .collect(); + } - let ap = if ap_port.is_some() || proxy.is_some() { - // filter on ports if specified on the command line... - aps.into_iter().find_map(|ap| { - if ap.parse::().ok()?.port()? == port { - Some(ap) - } else { - None - } + fn is_empty(&self) -> bool { + self.lock(|inner| { + inner.data.accesspoint.is_empty() + || inner.data.dealer.is_empty() + || inner.data.spclient.is_empty() }) - } else { - // ...or pick the first on the list - aps.into_iter().next() - } - .ok_or("Unable to resolve any viable access points.")?; - - Ok(ap) -} - -pub async fn apresolve(proxy: Option<&Url>, ap_port: Option) -> String { - try_apresolve(proxy, ap_port).await.unwrap_or_else(|e| { - warn!("Failed to resolve Access Point: {}", e); - warn!("Using fallback \"{}\"", AP_FALLBACK); - AP_FALLBACK.into() - }) -} - -#[cfg(test)] -mod test { - use std::net::ToSocketAddrs; - - use super::try_apresolve; - - #[tokio::test] - async fn test_apresolve() { - let ap = try_apresolve(None, None).await.unwrap(); - - // Assert that the result contains a valid host and port - ap.to_socket_addrs().unwrap().next().unwrap(); } - #[tokio::test] - async fn test_apresolve_port_443() { - let ap = try_apresolve(None, Some(443)).await.unwrap(); + pub async fn resolve(&self, endpoint: &str) -> Result { + if self.is_empty() { + self.apresolve().await; + } - let port = ap.to_socket_addrs().unwrap().next().unwrap().port(); - assert_eq!(port, 443); + self.lock(|inner| { + let access_point = match endpoint { + // take the first position instead of the last with `pop`, because Spotify returns + // access points with ports 4070, 443 and 80 in order of preference from highest + // to lowest. + "accesspoint" => inner.data.accesspoint.remove(0), + "dealer" => inner.data.dealer.remove(0), + "spclient" => inner.data.spclient.remove(0), + _ => { + return Err(Error::unimplemented(format!( + "No implementation to resolve access point {}", + endpoint + ))) + } + }; + + Ok(access_point) + }) } } diff --git a/core/src/audio_key.rs b/core/src/audio_key.rs index 3bce1c73..74be4258 100644 --- a/core/src/audio_key.rs +++ b/core/src/audio_key.rs @@ -1,52 +1,85 @@ +use std::{collections::HashMap, io::Write}; + use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; use bytes::Bytes; -use std::collections::HashMap; -use std::io::Write; +use thiserror::Error; use tokio::sync::oneshot; -use crate::spotify_id::{FileId, SpotifyId}; -use crate::util::SeqGenerator; +use crate::{packet::PacketType, util::SeqGenerator, Error, FileId, SpotifyId}; #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] pub struct AudioKey(pub [u8; 16]); -#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] -pub struct AudioKeyError; +#[derive(Debug, Error)] +pub enum AudioKeyError { + #[error("audio key error")] + AesKey, + #[error("other end of channel disconnected")] + Channel, + #[error("unexpected packet type {0}")] + Packet(u8), + #[error("sequence {0} not pending")] + Sequence(u32), +} + +impl From for Error { + fn from(err: AudioKeyError) -> Self { + match err { + AudioKeyError::AesKey => Error::unavailable(err), + AudioKeyError::Channel => Error::aborted(err), + AudioKeyError::Sequence(_) => Error::aborted(err), + AudioKeyError::Packet(_) => Error::unimplemented(err), + } + } +} component! { AudioKeyManager : AudioKeyManagerInner { sequence: SeqGenerator = SeqGenerator::new(0), - pending: HashMap>> = HashMap::new(), + pending: HashMap>> = HashMap::new(), } } impl AudioKeyManager { - pub(crate) fn dispatch(&self, cmd: u8, mut data: Bytes) { + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { let seq = BigEndian::read_u32(data.split_to(4).as_ref()); - let sender = self.lock(|inner| inner.pending.remove(&seq)); + let sender = self + .lock(|inner| inner.pending.remove(&seq)) + .ok_or(AudioKeyError::Sequence(seq))?; - if let Some(sender) = sender { - match cmd { - 0xd => { - let mut key = [0u8; 16]; - key.copy_from_slice(data.as_ref()); - let _ = sender.send(Ok(AudioKey(key))); - } - 0xe => { - warn!( - "error audio key {:x} {:x}", - data.as_ref()[0], - data.as_ref()[1] - ); - let _ = sender.send(Err(AudioKeyError)); - } - _ => (), + match cmd { + PacketType::AesKey => { + let mut key = [0u8; 16]; + key.copy_from_slice(data.as_ref()); + sender + .send(Ok(AudioKey(key))) + .map_err(|_| AudioKeyError::Channel)? + } + PacketType::AesKeyError => { + error!( + "error audio key {:x} {:x}", + data.as_ref()[0], + data.as_ref()[1] + ); + sender + .send(Err(AudioKeyError::AesKey.into())) + .map_err(|_| AudioKeyError::Channel)? + } + _ => { + trace!( + "Did not expect {:?} AES key packet with data {:#?}", + cmd, + data + ); + return Err(AudioKeyError::Packet(cmd as u8).into()); } } + + Ok(()) } - pub async fn request(&self, track: SpotifyId, file: FileId) -> Result { + pub async fn request(&self, track: SpotifyId, file: FileId) -> Result { let (tx, rx) = oneshot::channel(); let seq = self.lock(move |inner| { @@ -55,17 +88,17 @@ impl AudioKeyManager { seq }); - self.send_key_request(seq, track, file); - rx.await.map_err(|_| AudioKeyError)? + self.send_key_request(seq, track, file)?; + rx.await? } - fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) { + fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) -> Result<(), Error> { let mut data: Vec = Vec::new(); - data.write(&file.0).unwrap(); - data.write(&track.to_raw()).unwrap(); - data.write_u32::(seq).unwrap(); - data.write_u16::(0x0000).unwrap(); + data.write_all(&file.0)?; + data.write_all(&track.to_raw())?; + data.write_u32::(seq)?; + data.write_u16::(0x0000)?; - self.session().send_packet(0xc, data) + self.session().send_packet(PacketType::RequestKey, data) } } diff --git a/core/src/authentication.rs b/core/src/authentication.rs index 3c188ecf..dad514b0 100644 --- a/core/src/authentication.rs +++ b/core/src/authentication.rs @@ -7,8 +7,23 @@ use pbkdf2::pbkdf2; use protobuf::ProtobufEnum; use serde::{Deserialize, Serialize}; use sha1::{Digest, Sha1}; +use thiserror::Error; -use crate::protocol::authentication::AuthenticationType; +use crate::{protocol::authentication::AuthenticationType, Error}; + +#[derive(Debug, Error)] +pub enum AuthenticationError { + #[error("unknown authentication type {0}")] + AuthType(u32), + #[error("invalid key")] + Key, +} + +impl From for Error { + fn from(err: AuthenticationError) -> Self { + Error::invalid_argument(err) + } +} /// The credentials are used to log into the Spotify API. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -46,7 +61,7 @@ impl Credentials { username: impl Into, encrypted_blob: impl AsRef<[u8]>, device_id: impl AsRef<[u8]>, - ) -> Credentials { + ) -> Result { fn read_u8(stream: &mut R) -> io::Result { let mut data = [0u8]; stream.read_exact(&mut data)?; @@ -77,6 +92,10 @@ impl Credentials { let key = { let mut key = [0u8; 24]; + if key.len() < 20 { + return Err(AuthenticationError::Key.into()); + } + pbkdf2::>(&secret, username.as_bytes(), 0x100, &mut key[0..20]); let hash = &Sha1::digest(&key[..20]); @@ -87,15 +106,13 @@ impl Credentials { // decrypt data using ECB mode without padding let blob = { - use aes::cipher::generic_array::typenum::Unsigned; use aes::cipher::generic_array::GenericArray; - use aes::cipher::{BlockCipher, NewBlockCipher}; + use aes::cipher::{BlockDecrypt, BlockSizeUser, KeyInit}; - let mut data = base64::decode(encrypted_blob).unwrap(); + let mut data = base64::decode(encrypted_blob)?; let cipher = Aes192::new(GenericArray::from_slice(&key)); - let block_size = ::BlockSize::to_usize(); + let block_size = Aes192::block_size(); - assert_eq!(data.len() % block_size, 0); for chunk in data.chunks_exact_mut(block_size) { cipher.decrypt_block(GenericArray::from_mut_slice(chunk)); } @@ -109,19 +126,20 @@ impl Credentials { }; let mut cursor = io::Cursor::new(blob.as_slice()); - read_u8(&mut cursor).unwrap(); - read_bytes(&mut cursor).unwrap(); - read_u8(&mut cursor).unwrap(); - let auth_type = read_int(&mut cursor).unwrap(); - let auth_type = AuthenticationType::from_i32(auth_type as i32).unwrap(); - read_u8(&mut cursor).unwrap(); - let auth_data = read_bytes(&mut cursor).unwrap(); + read_u8(&mut cursor)?; + read_bytes(&mut cursor)?; + read_u8(&mut cursor)?; + let auth_type = read_int(&mut cursor)?; + let auth_type = AuthenticationType::from_i32(auth_type as i32) + .ok_or(AuthenticationError::AuthType(auth_type))?; + read_u8(&mut cursor)?; + let auth_data = read_bytes(&mut cursor)?; - Credentials { + Ok(Credentials { username, auth_type, auth_data, - } + }) } } diff --git a/core/src/cache.rs b/core/src/cache.rs index f8b0ca2c..11bfdf47 100644 --- a/core/src/cache.rs +++ b/core/src/cache.rs @@ -1,15 +1,30 @@ -use std::cmp::Reverse; -use std::collections::HashMap; -use std::fs::{self, File}; -use std::io::{self, Error, ErrorKind, Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::{ + cmp::Reverse, + collections::HashMap, + fs::{self, File}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + sync::Arc, + time::SystemTime, +}; +use parking_lot::Mutex; use priority_queue::PriorityQueue; +use thiserror::Error; -use crate::authentication::Credentials; -use crate::spotify_id::FileId; +use crate::{authentication::Credentials, error::ErrorKind, Error, FileId}; + +#[derive(Debug, Error)] +pub enum CacheError { + #[error("audio cache location is not configured")] + Path, +} + +impl From for Error { + fn from(err: CacheError) -> Self { + Error::failed_precondition(err) + } +} /// Some kind of data structure that holds some paths, the size of these files and a timestamp. /// It keeps track of the file sizes and is able to pop the path with the oldest timestamp if @@ -57,16 +72,17 @@ impl SizeLimiter { /// to delete the file in the file system. fn pop(&mut self) -> Option { if self.exceeds_limit() { - let (next, _) = self - .queue - .pop() - .expect("in_use was > 0, so the queue should have contained an item."); - let size = self - .sizes - .remove(&next) - .expect("`queue` and `sizes` should have the same keys."); - self.in_use -= size; - Some(next) + if let Some((next, _)) = self.queue.pop() { + if let Some(size) = self.sizes.remove(&next) { + self.in_use -= size; + } else { + error!("`queue` and `sizes` should have the same keys."); + } + Some(next) + } else { + error!("in_use was > 0, so the queue should have contained an item."); + None + } } else { None } @@ -85,11 +101,11 @@ impl SizeLimiter { return false; } - let size = self - .sizes - .remove(file) - .expect("`queue` and `sizes` should have the same keys."); - self.in_use -= size; + if let Some(size) = self.sizes.remove(file) { + self.in_use -= size; + } else { + error!("`queue` and `sizes` should have the same keys."); + } true } @@ -173,23 +189,21 @@ impl FsSizeLimiter { } fn add(&self, file: &Path, size: u64) { - self.limiter - .lock() - .unwrap() - .add(file, size, SystemTime::now()); + self.limiter.lock().add(file, size, SystemTime::now()) } fn touch(&self, file: &Path) -> bool { - self.limiter.lock().unwrap().update(file, SystemTime::now()) + self.limiter.lock().update(file, SystemTime::now()) } - fn remove(&self, file: &Path) { - self.limiter.lock().unwrap().remove(file); + fn remove(&self, file: &Path) -> bool { + self.limiter.lock().remove(file) } - fn prune_internal Option>(mut pop: F) { + fn prune_internal Option>(mut pop: F) -> Result<(), Error> { let mut first = true; let mut count = 0; + let mut last_error = None; while let Some(file) = pop() { if first { @@ -197,8 +211,10 @@ impl FsSizeLimiter { first = false; } - if let Err(e) = fs::remove_file(&file) { + let res = fs::remove_file(&file); + if let Err(e) = res { warn!("Could not remove file {:?} from cache dir: {}", file, e); + last_error = Some(e); } else { count += 1; } @@ -207,21 +223,27 @@ impl FsSizeLimiter { if count > 0 { info!("Removed {} cache files.", count); } + + if let Some(err) = last_error { + Err(err.into()) + } else { + Ok(()) + } } - fn prune(&self) { - Self::prune_internal(|| self.limiter.lock().unwrap().pop()) + fn prune(&self) -> Result<(), Error> { + Self::prune_internal(|| self.limiter.lock().pop()) } - fn new(path: &Path, limit: u64) -> Self { + fn new(path: &Path, limit: u64) -> Result { let mut limiter = SizeLimiter::new(limit); Self::init_dir(&mut limiter, path); - Self::prune_internal(|| limiter.pop()); + Self::prune_internal(|| limiter.pop())?; - Self { + Ok(Self { limiter: Mutex::new(limiter), - } + }) } } @@ -234,15 +256,13 @@ pub struct Cache { size_limiter: Option>, } -pub struct RemoveFileError(()); - impl Cache { pub fn new>( credentials_path: Option

, volume_path: Option

, audio_path: Option

, size_limit: Option, - ) -> io::Result { + ) -> Result { let mut size_limiter = None; if let Some(location) = &credentials_path { @@ -263,8 +283,7 @@ impl Cache { fs::create_dir_all(location)?; if let Some(limit) = size_limit { - let limiter = FsSizeLimiter::new(location.as_ref(), limit); - + let limiter = FsSizeLimiter::new(location.as_ref(), limit)?; size_limiter = Some(Arc::new(limiter)); } } @@ -285,11 +304,11 @@ impl Cache { let location = self.credentials_location.as_ref()?; // This closure is just convencience to enable the question mark operator - let read = || { + let read = || -> Result { let mut file = File::open(location)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; - serde_json::from_str(&contents).map_err(|e| Error::new(ErrorKind::InvalidData, e)) + Ok(serde_json::from_str(&contents)?) }; match read() { @@ -297,7 +316,7 @@ impl Cache { Err(e) => { // If the file did not exist, the file was probably not written // before. Otherwise, log the error. - if e.kind() != ErrorKind::NotFound { + if e.kind != ErrorKind::NotFound { warn!("Error reading credentials from cache: {}", e); } None @@ -321,19 +340,17 @@ impl Cache { pub fn volume(&self) -> Option { let location = self.volume_location.as_ref()?; - let read = || { + let read = || -> Result { let mut file = File::open(location)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; - contents - .parse() - .map_err(|e| Error::new(ErrorKind::InvalidData, e)) + Ok(contents.parse()?) }; match read() { Ok(v) => Some(v), Err(e) => { - if e.kind() != ErrorKind::NotFound { + if e.kind != ErrorKind::NotFound { warn!("Error reading volume from cache: {}", e); } None @@ -350,7 +367,7 @@ impl Cache { } } - fn file_path(&self, file: FileId) -> Option { + pub fn file_path(&self, file: FileId) -> Option { match file.to_base16() { Ok(name) => self.audio_location.as_ref().map(|location| { let mut path = location.join(&name[0..2]); @@ -358,7 +375,7 @@ impl Cache { path }), Err(e) => { - warn!("Invalid FileId: {}", e.utf8_error()); + warn!("Invalid FileId: {}", e); None } } @@ -369,12 +386,14 @@ impl Cache { match File::open(&path) { Ok(file) => { if let Some(limiter) = self.size_limiter.as_deref() { - limiter.touch(&path); + if !limiter.touch(&path) { + error!("limiter could not touch {:?}", path); + } } Some(file) } Err(e) => { - if e.kind() != ErrorKind::NotFound { + if e.kind() != io::ErrorKind::NotFound { warn!("Error reading file from cache: {}", e) } None @@ -382,38 +401,33 @@ impl Cache { } } - pub fn save_file(&self, file: FileId, contents: &mut F) { - let path = if let Some(path) = self.file_path(file) { - path - } else { - return; - }; - let parent = path.parent().unwrap(); - - let result = fs::create_dir_all(parent) - .and_then(|_| File::create(&path)) - .and_then(|mut file| io::copy(contents, &mut file)); - - if let Ok(size) = result { - if let Some(limiter) = self.size_limiter.as_deref() { - limiter.add(&path, size); - limiter.prune(); + pub fn save_file(&self, file: FileId, contents: &mut F) -> Result { + if let Some(path) = self.file_path(file) { + if let Some(parent) = path.parent() { + if let Ok(size) = fs::create_dir_all(parent) + .and_then(|_| File::create(&path)) + .and_then(|mut file| io::copy(contents, &mut file)) + { + if let Some(limiter) = self.size_limiter.as_deref() { + limiter.add(&path, size); + limiter.prune()?; + } + return Ok(path); + } } } + Err(CacheError::Path.into()) } - pub fn remove_file(&self, file: FileId) -> Result<(), RemoveFileError> { - let path = self.file_path(file).ok_or(RemoveFileError(()))?; + pub fn remove_file(&self, file: FileId) -> Result<(), Error> { + let path = self.file_path(file).ok_or(CacheError::Path)?; - if let Err(err) = fs::remove_file(&path) { - warn!("Unable to remove file from cache: {}", err); - Err(RemoveFileError(())) - } else { - if let Some(limiter) = self.size_limiter.as_deref() { - limiter.remove(&path); - } - Ok(()) + fs::remove_file(&path)?; + if let Some(limiter) = self.size_limiter.as_deref() { + limiter.remove(&path); } + + Ok(()) } } diff --git a/core/src/cdn_url.rs b/core/src/cdn_url.rs new file mode 100644 index 00000000..9df43ea9 --- /dev/null +++ b/core/src/cdn_url.rs @@ -0,0 +1,165 @@ +use std::{ + convert::TryFrom, + ops::{Deref, DerefMut}, +}; + +use protobuf::Message; +use thiserror::Error; +use url::Url; + +use super::{date::Date, Error, FileId, Session}; + +use librespot_protocol as protocol; +use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage; +use protocol::storage_resolve::StorageResolveResponse_Result; + +#[derive(Debug, Clone)] +pub struct MaybeExpiringUrl(pub String, pub Option); + +#[derive(Debug, Clone)] +pub struct MaybeExpiringUrls(pub Vec); + +impl Deref for MaybeExpiringUrls { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for MaybeExpiringUrls { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[derive(Debug, Error)] +pub enum CdnUrlError { + #[error("all URLs expired")] + Expired, + #[error("resolved storage is not for CDN")] + Storage, + #[error("no URLs resolved")] + Unresolved, +} + +impl From for Error { + fn from(err: CdnUrlError) -> Self { + match err { + CdnUrlError::Expired => Error::deadline_exceeded(err), + CdnUrlError::Storage | CdnUrlError::Unresolved => Error::unavailable(err), + } + } +} + +#[derive(Debug, Clone)] +pub struct CdnUrl { + pub file_id: FileId, + urls: MaybeExpiringUrls, +} + +impl CdnUrl { + pub fn new(file_id: FileId) -> Self { + Self { + file_id, + urls: MaybeExpiringUrls(Vec::new()), + } + } + + pub async fn resolve_audio(&self, session: &Session) -> Result { + let file_id = self.file_id; + let response = session.spclient().get_audio_storage(file_id).await?; + let msg = CdnUrlMessage::parse_from_bytes(&response)?; + let urls = MaybeExpiringUrls::try_from(msg)?; + + let cdn_url = Self { file_id, urls }; + + trace!("Resolved CDN storage: {:#?}", cdn_url); + + Ok(cdn_url) + } + + pub fn try_get_url(&self) -> Result<&str, Error> { + if self.urls.is_empty() { + return Err(CdnUrlError::Unresolved.into()); + } + + let now = Date::now_utc(); + let url = self.urls.iter().find(|url| match url.1 { + Some(expiry) => now < expiry, + None => true, + }); + + if let Some(url) = url { + Ok(&url.0) + } else { + Err(CdnUrlError::Expired.into()) + } + } +} + +impl TryFrom for MaybeExpiringUrls { + type Error = crate::Error; + fn try_from(msg: CdnUrlMessage) -> Result { + if !matches!(msg.get_result(), StorageResolveResponse_Result::CDN) { + return Err(CdnUrlError::Storage.into()); + } + + let is_expiring = !msg.get_fileid().is_empty(); + + let result = msg + .get_cdnurl() + .iter() + .map(|cdn_url| { + let url = Url::parse(cdn_url)?; + + if is_expiring { + let expiry_str = if let Some(token) = url + .query_pairs() + .into_iter() + .find(|(key, _value)| key == "__token__") + { + if let Some(mut start) = token.1.find("exp=") { + start += 4; + if token.1.len() >= start { + let slice = &token.1[start..]; + if let Some(end) = slice.find('~') { + // this is the only valid invariant for akamaized.net + String::from(&slice[..end]) + } else { + String::from(slice) + } + } else { + String::new() + } + } else { + String::new() + } + } else if let Some(query) = url.query() { + let mut items = query.split('_'); + if let Some(first) = items.next() { + // this is the only valid invariant for scdn.co + String::from(first) + } else { + String::new() + } + } else { + String::new() + }; + + let mut expiry: i64 = expiry_str.parse()?; + + expiry -= 5 * 60; // seconds + + Ok(MaybeExpiringUrl( + cdn_url.to_owned(), + Some(Date::from_timestamp_ms(expiry * 1000)?), + )) + } else { + Ok(MaybeExpiringUrl(cdn_url.to_owned(), None)) + } + }) + .collect::, Error>>()?; + + Ok(Self(result)) + } +} diff --git a/core/src/channel.rs b/core/src/channel.rs index 29c3c8aa..c601cd7a 100644 --- a/core/src/channel.rs +++ b/core/src/channel.rs @@ -1,16 +1,20 @@ -use std::collections::HashMap; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::Instant; +use std::{ + collections::HashMap, + fmt, + pin::Pin, + task::{Context, Poll}, + time::Instant, +}; use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; use futures_core::Stream; -use futures_util::lock::BiLock; -use futures_util::{ready, StreamExt}; +use futures_util::{lock::BiLock, ready, StreamExt}; +use num_traits::FromPrimitive; +use thiserror::Error; use tokio::sync::mpsc; -use crate::util::SeqGenerator; +use crate::{packet::PacketType, util::SeqGenerator, Error}; component! { ChannelManager : ChannelManagerInner { @@ -25,9 +29,21 @@ component! { const ONE_SECOND_IN_MS: usize = 1000; -#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] +#[derive(Debug, Error, Hash, PartialEq, Eq, Copy, Clone)] pub struct ChannelError; +impl From for Error { + fn from(err: ChannelError) -> Self { + Error::aborted(err) + } +} + +impl fmt::Display for ChannelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "channel error") + } +} + pub struct Channel { receiver: mpsc::UnboundedReceiver<(u8, Bytes)>, state: ChannelState, @@ -68,7 +84,7 @@ impl ChannelManager { (seq, channel) } - pub(crate) fn dispatch(&self, cmd: u8, mut data: Bytes) { + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { use std::collections::hash_map::Entry; let id: u16 = BigEndian::read_u16(data.split_to(2).as_ref()); @@ -92,9 +108,14 @@ impl ChannelManager { inner.download_measurement_bytes += data.len(); if let Entry::Occupied(entry) = inner.channels.entry(id) { - let _ = entry.get().send((cmd, data)); + entry + .get() + .send((cmd as u8, data)) + .map_err(|_| ChannelError)?; } - }); + + Ok(()) + }) } pub fn get_download_rate_estimate(&self) -> usize { @@ -114,7 +135,8 @@ impl Channel { fn recv_packet(&mut self, cx: &mut Context<'_>) -> Poll> { let (cmd, packet) = ready!(self.receiver.poll_recv(cx)).ok_or(ChannelError)?; - if cmd == 0xa { + let packet_type = FromPrimitive::from_u8(cmd); + if let Some(PacketType::ChannelError) = packet_type { let code = BigEndian::read_u16(&packet.as_ref()[..2]); error!("channel error: {} {}", packet.len(), code); @@ -139,7 +161,11 @@ impl Stream for Channel { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { loop { match self.state.clone() { - ChannelState::Closed => panic!("Polling already terminated channel"), + ChannelState::Closed => { + error!("Polling already terminated channel"); + return Poll::Ready(None); + } + ChannelState::Header(mut data) => { if data.is_empty() { data = ready!(self.recv_packet(cx))?; @@ -147,7 +173,6 @@ impl Stream for Channel { let length = BigEndian::read_u16(data.split_to(2).as_ref()) as usize; if length == 0 { - assert_eq!(data.len(), 0); self.state = ChannelState::Data; } else { let header_id = data.split_to(1).as_ref()[0]; diff --git a/core/src/component.rs b/core/src/component.rs index a761c455..ebe42e8d 100644 --- a/core/src/component.rs +++ b/core/src/component.rs @@ -1,20 +1,20 @@ macro_rules! component { ($name:ident : $inner:ident { $($key:ident : $ty:ty = $value:expr,)* }) => { #[derive(Clone)] - pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, ::std::sync::Mutex<$inner>)>); + pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, ::parking_lot::Mutex<$inner>)>); impl $name { #[allow(dead_code)] pub(crate) fn new(session: $crate::session::SessionWeak) -> $name { debug!(target:"librespot::component", "new {}", stringify!($name)); - $name(::std::sync::Arc::new((session, ::std::sync::Mutex::new($inner { + $name(::std::sync::Arc::new((session, ::parking_lot::Mutex::new($inner { $($key : $value,)* })))) } #[allow(dead_code)] fn lock R, R>(&self, f: F) -> R { - let mut inner = (self.0).1.lock().expect("Mutex poisoned"); + let mut inner = (self.0).1.lock(); f(&mut inner) } diff --git a/core/src/config.rs b/core/src/config.rs index 8ead5654..46f11fe8 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,23 +1,27 @@ -use std::fmt; -use std::str::FromStr; +use std::{fmt, path::PathBuf, str::FromStr}; + use url::Url; +const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; + #[derive(Clone, Debug)] pub struct SessionConfig { - pub user_agent: String, + pub client_id: String, pub device_id: String, pub proxy: Option, pub ap_port: Option, + pub tmp_dir: PathBuf, } impl Default for SessionConfig { fn default() -> SessionConfig { let device_id = uuid::Uuid::new_v4().as_hyphenated().to_string(); SessionConfig { - user_agent: crate::version::VERSION_STRING.to_string(), + client_id: KEYMASTER_CLIENT_ID.to_owned(), device_id, proxy: None, ap_port: None, + tmp_dir: std::env::temp_dir(), } } } @@ -116,24 +120,3 @@ impl Default for DeviceType { DeviceType::Speaker } } - -#[derive(Clone, Debug)] -pub struct ConnectConfig { - pub name: String, - pub device_type: DeviceType, - pub initial_volume: Option, - pub has_volume_ctrl: bool, - pub autoplay: bool, -} - -impl Default for ConnectConfig { - fn default() -> ConnectConfig { - ConnectConfig { - name: "Librespot".to_string(), - device_type: DeviceType::default(), - initial_volume: Some(50), - has_volume_ctrl: true, - autoplay: false, - } - } -} diff --git a/core/src/connection/codec.rs b/core/src/connection/codec.rs index 86533aaf..826839c6 100644 --- a/core/src/connection/codec.rs +++ b/core/src/connection/codec.rs @@ -1,12 +1,20 @@ +use std::io; + use byteorder::{BigEndian, ByteOrder}; use bytes::{BufMut, Bytes, BytesMut}; use shannon::Shannon; -use std::io; +use thiserror::Error; use tokio_util::codec::{Decoder, Encoder}; const HEADER_SIZE: usize = 3; const MAC_SIZE: usize = 4; +#[derive(Debug, Error)] +pub enum ApCodecError { + #[error("payload was malformed")] + Payload, +} + #[derive(Debug)] enum DecodeState { Header, @@ -87,7 +95,10 @@ impl Decoder for ApCodec { let mut payload = buf.split_to(size + MAC_SIZE); - self.decode_cipher.decrypt(payload.get_mut(..size).unwrap()); + self.decode_cipher + .decrypt(payload.get_mut(..size).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, ApCodecError::Payload) + })?); let mac = payload.split_off(size); self.decode_cipher.check_mac(mac.as_ref())?; diff --git a/core/src/connection/handshake.rs b/core/src/connection/handshake.rs index eddcd327..680f512e 100644 --- a/core/src/connection/handshake.rs +++ b/core/src/connection/handshake.rs @@ -1,16 +1,50 @@ +use std::{env::consts::ARCH, io}; + use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; -use hmac::{Hmac, Mac, NewMac}; +use hmac::{Hmac, Mac}; use protobuf::{self, Message}; use rand::{thread_rng, RngCore}; -use sha1::Sha1; -use std::io; +use rsa::{BigUint, PublicKey}; +use sha1::{Digest, Sha1}; +use thiserror::Error; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio_util::codec::{Decoder, Framed}; use super::codec::ApCodec; -use crate::diffie_hellman::DhLocalKeys; + +use crate::{diffie_hellman::DhLocalKeys, version}; + use crate::protocol; -use crate::protocol::keyexchange::{APResponseMessage, ClientHello, ClientResponsePlaintext}; +use crate::protocol::keyexchange::{ + APResponseMessage, ClientHello, ClientResponsePlaintext, Platform, ProductFlags, +}; + +const SERVER_KEY: [u8; 256] = [ + 0xac, 0xe0, 0x46, 0x0b, 0xff, 0xc2, 0x30, 0xaf, 0xf4, 0x6b, 0xfe, 0xc3, 0xbf, 0xbf, 0x86, 0x3d, + 0xa1, 0x91, 0xc6, 0xcc, 0x33, 0x6c, 0x93, 0xa1, 0x4f, 0xb3, 0xb0, 0x16, 0x12, 0xac, 0xac, 0x6a, + 0xf1, 0x80, 0xe7, 0xf6, 0x14, 0xd9, 0x42, 0x9d, 0xbe, 0x2e, 0x34, 0x66, 0x43, 0xe3, 0x62, 0xd2, + 0x32, 0x7a, 0x1a, 0x0d, 0x92, 0x3b, 0xae, 0xdd, 0x14, 0x02, 0xb1, 0x81, 0x55, 0x05, 0x61, 0x04, + 0xd5, 0x2c, 0x96, 0xa4, 0x4c, 0x1e, 0xcc, 0x02, 0x4a, 0xd4, 0xb2, 0x0c, 0x00, 0x1f, 0x17, 0xed, + 0xc2, 0x2f, 0xc4, 0x35, 0x21, 0xc8, 0xf0, 0xcb, 0xae, 0xd2, 0xad, 0xd7, 0x2b, 0x0f, 0x9d, 0xb3, + 0xc5, 0x32, 0x1a, 0x2a, 0xfe, 0x59, 0xf3, 0x5a, 0x0d, 0xac, 0x68, 0xf1, 0xfa, 0x62, 0x1e, 0xfb, + 0x2c, 0x8d, 0x0c, 0xb7, 0x39, 0x2d, 0x92, 0x47, 0xe3, 0xd7, 0x35, 0x1a, 0x6d, 0xbd, 0x24, 0xc2, + 0xae, 0x25, 0x5b, 0x88, 0xff, 0xab, 0x73, 0x29, 0x8a, 0x0b, 0xcc, 0xcd, 0x0c, 0x58, 0x67, 0x31, + 0x89, 0xe8, 0xbd, 0x34, 0x80, 0x78, 0x4a, 0x5f, 0xc9, 0x6b, 0x89, 0x9d, 0x95, 0x6b, 0xfc, 0x86, + 0xd7, 0x4f, 0x33, 0xa6, 0x78, 0x17, 0x96, 0xc9, 0xc3, 0x2d, 0x0d, 0x32, 0xa5, 0xab, 0xcd, 0x05, + 0x27, 0xe2, 0xf7, 0x10, 0xa3, 0x96, 0x13, 0xc4, 0x2f, 0x99, 0xc0, 0x27, 0xbf, 0xed, 0x04, 0x9c, + 0x3c, 0x27, 0x58, 0x04, 0xb6, 0xb2, 0x19, 0xf9, 0xc1, 0x2f, 0x02, 0xe9, 0x48, 0x63, 0xec, 0xa1, + 0xb6, 0x42, 0xa0, 0x9d, 0x48, 0x25, 0xf8, 0xb3, 0x9d, 0xd0, 0xe8, 0x6a, 0xf9, 0x48, 0x4d, 0xa1, + 0xc2, 0xba, 0x86, 0x30, 0x42, 0xea, 0x9d, 0xb3, 0x08, 0x6c, 0x19, 0x0e, 0x48, 0xb3, 0x9d, 0x66, + 0xeb, 0x00, 0x06, 0xa2, 0x5a, 0xee, 0xa1, 0x1b, 0x13, 0x87, 0x3c, 0xd7, 0x19, 0xe6, 0x55, 0xbd, +]; + +#[derive(Debug, Error)] +pub enum HandshakeError { + #[error("invalid key length")] + InvalidLength, + #[error("server key verification failed")] + VerificationFailed, +} pub async fn handshake( mut connection: T, @@ -25,9 +59,37 @@ pub async fn handshake( .get_diffie_hellman() .get_gs() .to_owned(); + let remote_signature = message + .get_challenge() + .get_login_crypto_challenge() + .get_diffie_hellman() + .get_gs_signature() + .to_owned(); + // Prevent man-in-the-middle attacks: check server signature + let n = BigUint::from_bytes_be(&SERVER_KEY); + let e = BigUint::new(vec![65537]); + let public_key = rsa::RsaPublicKey::new(n, e).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + HandshakeError::VerificationFailed, + ) + })?; + + let hash = Sha1::digest(&remote_key); + let padding = rsa::padding::PaddingScheme::new_pkcs1v15_sign(Some(rsa::hash::Hash::SHA1)); + public_key + .verify(padding, &hash, &remote_signature) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + HandshakeError::VerificationFailed, + ) + })?; + + // OK to proceed let shared_secret = local_keys.shared_secret(&remote_key); - let (challenge, send_key, recv_key) = compute_keys(&shared_secret, &accumulator); + let (challenge, send_key, recv_key) = compute_keys(&shared_secret, &accumulator)?; let codec = ApCodec::new(&send_key, &recv_key); client_response(&mut connection, challenge).await?; @@ -42,14 +104,56 @@ where let mut client_nonce = vec![0; 0x10]; thread_rng().fill_bytes(&mut client_nonce); + let platform = match std::env::consts::OS { + "android" => Platform::PLATFORM_ANDROID_ARM, + "freebsd" | "netbsd" | "openbsd" => match ARCH { + "x86_64" => Platform::PLATFORM_FREEBSD_X86_64, + _ => Platform::PLATFORM_FREEBSD_X86, + }, + "ios" => match ARCH { + "aarch64" => Platform::PLATFORM_IPHONE_ARM64, + _ => Platform::PLATFORM_IPHONE_ARM, + }, + "linux" => match ARCH { + "arm" | "aarch64" => Platform::PLATFORM_LINUX_ARM, + "blackfin" => Platform::PLATFORM_LINUX_BLACKFIN, + "mips" => Platform::PLATFORM_LINUX_MIPS, + "sh" => Platform::PLATFORM_LINUX_SH, + "x86_64" => Platform::PLATFORM_LINUX_X86_64, + _ => Platform::PLATFORM_LINUX_X86, + }, + "macos" => match ARCH { + "ppc" | "ppc64" => Platform::PLATFORM_OSX_PPC, + "x86_64" => Platform::PLATFORM_OSX_X86_64, + _ => Platform::PLATFORM_OSX_X86, + }, + "windows" => match ARCH { + "arm" | "aarch64" => Platform::PLATFORM_WINDOWS_CE_ARM, + "x86_64" => Platform::PLATFORM_WIN32_X86_64, + _ => Platform::PLATFORM_WIN32_X86, + }, + _ => Platform::PLATFORM_LINUX_X86, + }; + + #[cfg(debug_assertions)] + const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_DEV_BUILD; + #[cfg(not(debug_assertions))] + const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_NONE; + let mut packet = ClientHello::new(); packet .mut_build_info() - .set_product(protocol::keyexchange::Product::PRODUCT_PARTNER); + // ProductInfo won't push autoplay and perhaps other settings + // when set to anything else than PRODUCT_CLIENT + .set_product(protocol::keyexchange::Product::PRODUCT_CLIENT); packet .mut_build_info() - .set_platform(protocol::keyexchange::Platform::PLATFORM_LINUX_X86); - packet.mut_build_info().set_version(109800078); + .mut_product_flags() + .push(PRODUCT_FLAGS); + packet.mut_build_info().set_platform(platform); + packet + .mut_build_info() + .set_version(version::SPOTIFY_VERSION); packet .mut_cryptosuites_supported() .push(protocol::keyexchange::Cryptosuite::CRYPTO_SUITE_SHANNON); @@ -66,8 +170,8 @@ where let mut buffer = vec![0, 4]; let size = 2 + 4 + packet.compute_size(); - as WriteBytesExt>::write_u32::(&mut buffer, size).unwrap(); - packet.write_to_vec(&mut buffer).unwrap(); + as WriteBytesExt>::write_u32::(&mut buffer, size)?; + packet.write_to_vec(&mut buffer)?; connection.write_all(&buffer[..]).await?; Ok(buffer) @@ -87,8 +191,8 @@ where let mut buffer = vec![]; let size = 4 + packet.compute_size(); - as WriteBytesExt>::write_u32::(&mut buffer, size).unwrap(); - packet.write_to_vec(&mut buffer).unwrap(); + as WriteBytesExt>::write_u32::(&mut buffer, size)?; + packet.write_to_vec(&mut buffer)?; connection.write_all(&buffer[..]).await?; Ok(()) @@ -102,7 +206,7 @@ where let header = read_into_accumulator(connection, 4, acc).await?; let size = BigEndian::read_u32(header) as usize; let data = read_into_accumulator(connection, size - 4, acc).await?; - let message = M::parse_from_bytes(data).unwrap(); + let message = M::parse_from_bytes(data)?; Ok(message) } @@ -118,24 +222,26 @@ async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>( Ok(&mut acc[offset..]) } -fn compute_keys(shared_secret: &[u8], packets: &[u8]) -> (Vec, Vec, Vec) { +fn compute_keys(shared_secret: &[u8], packets: &[u8]) -> io::Result<(Vec, Vec, Vec)> { type HmacSha1 = Hmac; let mut data = Vec::with_capacity(0x64); for i in 1..6 { - let mut mac = - HmacSha1::new_from_slice(shared_secret).expect("HMAC can take key of any size"); + let mut mac = HmacSha1::new_from_slice(shared_secret).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength) + })?; mac.update(packets); mac.update(&[i]); data.extend_from_slice(&mac.finalize().into_bytes()); } - let mut mac = HmacSha1::new_from_slice(&data[..0x14]).expect("HMAC can take key of any size"); + let mut mac = HmacSha1::new_from_slice(&data[..0x14]) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength))?; mac.update(packets); - ( + Ok(( mac.finalize().into_bytes().to_vec(), data[0x14..0x34].to_vec(), data[0x34..0x54].to_vec(), - ) + )) } diff --git a/core/src/connection/mod.rs b/core/src/connection/mod.rs index 58d3e83a..0b59de88 100644 --- a/core/src/connection/mod.rs +++ b/core/src/connection/mod.rs @@ -1,23 +1,21 @@ mod codec; mod handshake; -pub use self::codec::ApCodec; -pub use self::handshake::handshake; +pub use self::{codec::ApCodec, handshake::handshake}; -use std::io::{self, ErrorKind}; -use std::net::ToSocketAddrs; +use std::io; use futures_util::{SinkExt, StreamExt}; -use protobuf::{self, Message, ProtobufError}; +use num_traits::FromPrimitive; +use protobuf::{self, Message}; use thiserror::Error; use tokio::net::TcpStream; use tokio_util::codec::Framed; use url::Url; -use crate::authentication::Credentials; +use crate::{authentication::Credentials, packet::PacketType, version, Error}; + use crate::protocol::keyexchange::{APLoginFailed, ErrorCode}; -use crate::proxytunnel; -use crate::version; pub type Transport = Framed; @@ -42,13 +40,19 @@ fn login_error_message(code: &ErrorCode) -> &'static str { pub enum AuthenticationError { #[error("Login failed with reason: {}", login_error_message(.0))] LoginFailed(ErrorCode), - #[error("Authentication failed: {0}")] - IoError(#[from] io::Error), + #[error("invalid packet {0}")] + Packet(u8), + #[error("transport returned no data")] + Transport, } -impl From for AuthenticationError { - fn from(e: ProtobufError) -> Self { - io::Error::new(ErrorKind::InvalidData, e).into() +impl From for Error { + fn from(err: AuthenticationError) -> Self { + match err { + AuthenticationError::LoginFailed(_) => Error::permission_denied(err), + AuthenticationError::Packet(_) => Error::unimplemented(err), + AuthenticationError::Transport => Error::unavailable(err), + } } } @@ -58,50 +62,8 @@ impl From for AuthenticationError { } } -pub async fn connect(addr: String, proxy: Option<&Url>) -> io::Result { - let socket = if let Some(proxy_url) = proxy { - info!("Using proxy \"{}\"", proxy_url); - - let socket_addr = proxy_url.socket_addrs(|| None).and_then(|addrs| { - addrs.into_iter().next().ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "Can't resolve proxy server address", - ) - }) - })?; - let socket = TcpStream::connect(&socket_addr).await?; - - let uri = addr.parse::().map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidData, - "Can't parse access point address", - ) - })?; - let host = uri.host().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "The access point address contains no hostname", - ) - })?; - let port = uri.port().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "The access point address contains no port", - ) - })?; - - proxytunnel::proxy_connect(socket, host, port.as_str()).await? - } else { - let socket_addr = addr.to_socket_addrs()?.next().ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "Can't resolve access point address", - ) - })?; - - TcpStream::connect(&socket_addr).await? - }; +pub async fn connect(host: &str, port: u16, proxy: Option<&Url>) -> io::Result { + let socket = crate::socket::connect(host, port, proxy).await?; handshake(socket).await } @@ -110,9 +72,32 @@ pub async fn authenticate( transport: &mut Transport, credentials: Credentials, device_id: &str, -) -> Result { +) -> Result { use crate::protocol::authentication::{APWelcome, ClientResponseEncrypted, CpuFamily, Os}; + let cpu_family = match std::env::consts::ARCH { + "blackfin" => CpuFamily::CPU_BLACKFIN, + "arm" | "arm64" => CpuFamily::CPU_ARM, + "ia64" => CpuFamily::CPU_IA64, + "mips" => CpuFamily::CPU_MIPS, + "ppc" => CpuFamily::CPU_PPC, + "ppc64" => CpuFamily::CPU_PPC_64, + "sh" => CpuFamily::CPU_SH, + "x86" => CpuFamily::CPU_X86, + "x86_64" => CpuFamily::CPU_X86_64, + _ => CpuFamily::CPU_UNKNOWN, + }; + + let os = match std::env::consts::OS { + "android" => Os::OS_ANDROID, + "freebsd" | "netbsd" | "openbsd" => Os::OS_FREEBSD, + "ios" => Os::OS_IPHONE, + "linux" => Os::OS_LINUX, + "macos" => Os::OS_OSX, + "windows" => Os::OS_WINDOWS, + _ => Os::OS_UNKNOWN, + }; + let mut packet = ClientResponseEncrypted::new(); packet .mut_login_credentials() @@ -123,29 +108,31 @@ pub async fn authenticate( packet .mut_login_credentials() .set_auth_data(credentials.auth_data); - packet - .mut_system_info() - .set_cpu_family(CpuFamily::CPU_UNKNOWN); - packet.mut_system_info().set_os(Os::OS_UNKNOWN); + packet.mut_system_info().set_cpu_family(cpu_family); + packet.mut_system_info().set_os(os); packet .mut_system_info() .set_system_information_string(format!( - "librespot_{}_{}", + "librespot-{}-{}", version::SHA_SHORT, version::BUILD_ID )); packet .mut_system_info() .set_device_id(device_id.to_string()); - packet.set_version_string(version::VERSION_STRING.to_string()); + packet.set_version_string(format!("librespot {}", version::SEMVER)); - let cmd = 0xab; - let data = packet.write_to_bytes().unwrap(); + let cmd = PacketType::Login; + let data = packet.write_to_bytes()?; - transport.send((cmd, data)).await?; - let (cmd, data) = transport.next().await.expect("EOF")?; - match cmd { - 0xac => { + transport.send((cmd as u8, data)).await?; + let (cmd, data) = transport + .next() + .await + .ok_or(AuthenticationError::Transport)??; + let packet_type = FromPrimitive::from_u8(cmd); + let result = match packet_type { + Some(PacketType::APWelcome) => { let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?; let reusable_credentials = Credentials { @@ -156,13 +143,18 @@ pub async fn authenticate( Ok(reusable_credentials) } - 0xad => { + Some(PacketType::AuthFailure) => { let error_data = APLoginFailed::parse_from_bytes(data.as_ref())?; Err(error_data.into()) } _ => { - let msg = format!("Received invalid packet: {}", cmd); - Err(io::Error::new(ErrorKind::InvalidData, msg).into()) + trace!( + "Did not expect {:?} AES key packet with data {:#?}", + cmd, + data + ); + Err(AuthenticationError::Packet(cmd)) } - } + }; + Ok(result?) } diff --git a/core/src/date.rs b/core/src/date.rs new file mode 100644 index 00000000..c9aadce8 --- /dev/null +++ b/core/src/date.rs @@ -0,0 +1,81 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use time::{error::ComponentRange, Date as _Date, OffsetDateTime, PrimitiveDateTime, Time}; + +use crate::Error; + +use librespot_protocol as protocol; +use protocol::metadata::Date as DateMessage; + +impl From for Error { + fn from(err: ComponentRange) -> Self { + Error::out_of_range(err) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Date(pub OffsetDateTime); + +impl Deref for Date { + type Target = OffsetDateTime; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Date { + pub fn as_timestamp_ms(&self) -> i64 { + (self.0.unix_timestamp_nanos() / 1_000_000) as i64 + } + + pub fn from_timestamp_ms(timestamp: i64) -> Result { + let date_time = OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128 * 1_000_000)?; + Ok(Self(date_time)) + } + + pub fn as_utc(&self) -> OffsetDateTime { + self.0 + } + + pub fn from_utc(date_time: PrimitiveDateTime) -> Self { + Self(date_time.assume_utc()) + } + + pub fn now_utc() -> Self { + Self(OffsetDateTime::now_utc()) + } +} + +impl TryFrom<&DateMessage> for Date { + type Error = crate::Error; + fn try_from(msg: &DateMessage) -> Result { + // Some metadata contains a year, but no month. In that case just set January. + let month = if msg.has_month() { + msg.get_month() as u8 + } else { + 1 + }; + + // Having no day will work, but may be unexpected: it will imply the last day + // of the month before. So prevent that, and just set day 1. + let day = if msg.has_day() { + msg.get_day() as u8 + } else { + 1 + }; + + let date = _Date::from_calendar_date(msg.get_year(), month.try_into()?, day)?; + let time = Time::from_hms(msg.get_hour() as u8, msg.get_minute() as u8, 0)?; + Ok(Self::from_utc(PrimitiveDateTime::new(date, time))) + } +} + +impl From for Date { + fn from(datetime: OffsetDateTime) -> Self { + Self(datetime) + } +} diff --git a/core/src/dealer/maps.rs b/core/src/dealer/maps.rs new file mode 100644 index 00000000..4f719de7 --- /dev/null +++ b/core/src/dealer/maps.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; + +use thiserror::Error; + +use crate::Error; + +#[derive(Debug, Error)] +pub enum HandlerMapError { + #[error("request was already handled")] + AlreadyHandled, +} + +impl From for Error { + fn from(err: HandlerMapError) -> Self { + Error::aborted(err) + } +} + +pub enum HandlerMap { + Leaf(T), + Branch(HashMap>), +} + +impl Default for HandlerMap { + fn default() -> Self { + Self::Branch(HashMap::new()) + } +} + +impl HandlerMap { + pub fn insert<'a>( + &mut self, + mut path: impl Iterator, + handler: T, + ) -> Result<(), Error> { + match self { + Self::Leaf(_) => Err(HandlerMapError::AlreadyHandled.into()), + Self::Branch(children) => { + if let Some(component) = path.next() { + let node = children.entry(component.to_owned()).or_default(); + node.insert(path, handler) + } else if children.is_empty() { + *self = Self::Leaf(handler); + Ok(()) + } else { + Err(HandlerMapError::AlreadyHandled.into()) + } + } + } + } + + pub fn get<'a>(&self, mut path: impl Iterator) -> Option<&T> { + match self { + Self::Leaf(t) => Some(t), + Self::Branch(m) => { + let component = path.next()?; + m.get(component)?.get(path) + } + } + } + + pub fn remove<'a>(&mut self, mut path: impl Iterator) -> Option { + match self { + Self::Leaf(_) => match std::mem::take(self) { + Self::Leaf(t) => Some(t), + _ => unreachable!(), + }, + Self::Branch(map) => { + let component = path.next()?; + let next = map.get_mut(component)?; + let result = next.remove(path); + match &*next { + Self::Branch(b) if b.is_empty() => { + map.remove(component); + } + _ => (), + } + result + } + } + } +} + +pub struct SubscriberMap { + subscribed: Vec, + children: HashMap>, +} + +impl Default for SubscriberMap { + fn default() -> Self { + Self { + subscribed: Vec::new(), + children: HashMap::new(), + } + } +} + +impl SubscriberMap { + pub fn insert<'a>(&mut self, mut path: impl Iterator, handler: T) { + if let Some(component) = path.next() { + self.children + .entry(component.to_owned()) + .or_default() + .insert(path, handler); + } else { + self.subscribed.push(handler); + } + } + + pub fn is_empty(&self) -> bool { + self.children.is_empty() && self.subscribed.is_empty() + } + + pub fn retain<'a>( + &mut self, + mut path: impl Iterator, + fun: &mut impl FnMut(&T) -> bool, + ) { + self.subscribed.retain(|x| fun(x)); + + if let Some(next) = path.next() { + if let Some(y) = self.children.get_mut(next) { + y.retain(path, fun); + if y.is_empty() { + self.children.remove(next); + } + } + } + } +} diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs new file mode 100644 index 00000000..b4cfec8e --- /dev/null +++ b/core/src/dealer/mod.rs @@ -0,0 +1,612 @@ +mod maps; +pub mod protocol; + +use std::{ + iter, + pin::Pin, + sync::{ + atomic::{self, AtomicBool}, + Arc, + }, + task::Poll, + time::Duration, +}; + +use futures_core::{Future, Stream}; +use futures_util::{future::join_all, SinkExt, StreamExt}; +use parking_lot::Mutex; +use thiserror::Error; +use tokio::{ + select, + sync::{ + mpsc::{self, UnboundedReceiver}, + Semaphore, + }, + task::JoinHandle, +}; +use tokio_tungstenite::tungstenite; +use tungstenite::error::UrlError; +use url::Url; + +use self::maps::*; +use self::protocol::*; + +use crate::{ + socket, + util::{keep_flushing, CancelOnDrop, TimeoutOnDrop}, + Error, +}; + +type WsMessage = tungstenite::Message; +type WsError = tungstenite::Error; +type WsResult = Result; + +const WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(3); + +const PING_INTERVAL: Duration = Duration::from_secs(30); +const PING_TIMEOUT: Duration = Duration::from_secs(3); + +const RECONNECT_INTERVAL: Duration = Duration::from_secs(10); + +pub struct Response { + pub success: bool, +} + +pub struct Responder { + key: String, + tx: mpsc::UnboundedSender, + sent: bool, +} + +impl Responder { + fn new(key: String, tx: mpsc::UnboundedSender) -> Self { + Self { + key, + tx, + sent: false, + } + } + + // Should only be called once + fn send_internal(&mut self, response: Response) { + let response = serde_json::json!({ + "type": "reply", + "key": &self.key, + "payload": { + "success": response.success, + } + }) + .to_string(); + + if let Err(e) = self.tx.send(WsMessage::Text(response)) { + warn!("Wasn't able to reply to dealer request: {}", e); + } + } + + pub fn send(mut self, response: Response) { + self.send_internal(response); + self.sent = true; + } + + pub fn force_unanswered(mut self) { + self.sent = true; + } +} + +impl Drop for Responder { + fn drop(&mut self) { + if !self.sent { + self.send_internal(Response { success: false }); + } + } +} + +pub trait IntoResponse { + fn respond(self, responder: Responder); +} + +impl IntoResponse for Response { + fn respond(self, responder: Responder) { + responder.send(self) + } +} + +impl IntoResponse for F +where + F: Future + Send + 'static, +{ + fn respond(self, responder: Responder) { + tokio::spawn(async move { + responder.send(self.await); + }); + } +} + +impl RequestHandler for F +where + F: (Fn(Request) -> R) + Send + 'static, + R: IntoResponse, +{ + fn handle_request(&self, request: Request, responder: Responder) { + self(request).respond(responder); + } +} + +pub trait RequestHandler: Send + 'static { + fn handle_request(&self, request: Request, responder: Responder); +} + +type MessageHandler = mpsc::UnboundedSender; + +// TODO: Maybe it's possible to unregister subscription directly when they +// are dropped instead of on next failed attempt. +pub struct Subscription(UnboundedReceiver); + +impl Stream for Subscription { + type Item = Message; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + self.0.poll_recv(cx) + } +} + +fn split_uri(s: &str) -> Option> { + let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix("hm://") { + ("hm", '/', rest) + } else if let Some(rest) = s.strip_suffix("spotify:") { + ("spotify", ':', rest) + } else { + return None; + }; + + let rest = rest.trim_end_matches(sep); + let split = rest.split(sep); + + Some(iter::once(scheme).chain(split)) +} + +#[derive(Debug, Clone, Error)] +pub enum AddHandlerError { + #[error("There is already a handler for the given uri")] + AlreadyHandled, + #[error("The specified uri {0} is invalid")] + InvalidUri(String), +} + +impl From for Error { + fn from(err: AddHandlerError) -> Self { + match err { + AddHandlerError::AlreadyHandled => Error::aborted(err), + AddHandlerError::InvalidUri(_) => Error::invalid_argument(err), + } + } +} + +#[derive(Debug, Clone, Error)] +pub enum SubscriptionError { + #[error("The specified uri is invalid")] + InvalidUri(String), +} + +impl From for Error { + fn from(err: SubscriptionError) -> Self { + Error::invalid_argument(err) + } +} + +fn add_handler( + map: &mut HandlerMap>, + uri: &str, + handler: impl RequestHandler, +) -> Result<(), Error> { + let split = split_uri(uri).ok_or_else(|| AddHandlerError::InvalidUri(uri.to_string()))?; + map.insert(split, Box::new(handler)) +} + +fn remove_handler(map: &mut HandlerMap, uri: &str) -> Option { + map.remove(split_uri(uri)?) +} + +fn subscribe( + map: &mut SubscriberMap, + uris: &[&str], +) -> Result { + let (tx, rx) = mpsc::unbounded_channel(); + + for &uri in uris { + let split = split_uri(uri).ok_or_else(|| SubscriptionError::InvalidUri(uri.to_string()))?; + map.insert(split, tx.clone()); + } + + Ok(Subscription(rx)) +} + +#[derive(Default)] +pub struct Builder { + message_handlers: SubscriberMap, + request_handlers: HandlerMap>, +} + +macro_rules! create_dealer { + ($builder:expr, $shared:ident -> $body:expr) => { + match $builder { + builder => { + let shared = Arc::new(DealerShared { + message_handlers: Mutex::new(builder.message_handlers), + request_handlers: Mutex::new(builder.request_handlers), + notify_drop: Semaphore::new(0), + }); + + let handle = { + let $shared = Arc::clone(&shared); + tokio::spawn($body) + }; + + Dealer { + shared, + handle: TimeoutOnDrop::new(handle, WEBSOCKET_CLOSE_TIMEOUT), + } + } + } + }; +} + +impl Builder { + pub fn new() -> Self { + Self::default() + } + + pub fn add_handler(&mut self, uri: &str, handler: impl RequestHandler) -> Result<(), Error> { + add_handler(&mut self.request_handlers, uri, handler) + } + + pub fn subscribe(&mut self, uris: &[&str]) -> Result { + subscribe(&mut self.message_handlers, uris) + } + + pub fn launch_in_background(self, get_url: F, proxy: Option) -> Dealer + where + Fut: Future + Send + 'static, + F: (FnMut() -> Fut) + Send + 'static, + { + create_dealer!(self, shared -> run(shared, None, get_url, proxy)) + } + + pub async fn launch(self, mut get_url: F, proxy: Option) -> WsResult + where + Fut: Future + Send + 'static, + F: (FnMut() -> Fut) + Send + 'static, + { + let dealer = create_dealer!(self, shared -> { + // Try to connect. + let url = get_url().await; + let tasks = connect(&url, proxy.as_ref(), &shared).await?; + + // If a connection is established, continue in a background task. + run(shared, Some(tasks), get_url, proxy) + }); + + Ok(dealer) + } +} + +struct DealerShared { + message_handlers: Mutex>, + request_handlers: Mutex>>, + + // Semaphore with 0 permits. By closing this semaphore, we indicate + // that the actual Dealer struct has been dropped. + notify_drop: Semaphore, +} + +impl DealerShared { + fn dispatch_message(&self, msg: Message) { + if let Some(split) = split_uri(&msg.uri) { + self.message_handlers + .lock() + .retain(split, &mut |tx| tx.send(msg.clone()).is_ok()); + } + } + + fn dispatch_request(&self, request: Request, send_tx: &mpsc::UnboundedSender) { + // ResponseSender will automatically send "success: false" if it is dropped without an answer. + let responder = Responder::new(request.key.clone(), send_tx.clone()); + + let split = if let Some(split) = split_uri(&request.message_ident) { + split + } else { + warn!( + "Dealer request with invalid message_ident: {}", + &request.message_ident + ); + return; + }; + + { + let handler_map = self.request_handlers.lock(); + + if let Some(handler) = handler_map.get(split) { + handler.handle_request(request, responder); + return; + } + } + + warn!("No handler for message_ident: {}", &request.message_ident); + } + + fn dispatch(&self, m: MessageOrRequest, send_tx: &mpsc::UnboundedSender) { + match m { + MessageOrRequest::Message(m) => self.dispatch_message(m), + MessageOrRequest::Request(r) => self.dispatch_request(r, send_tx), + } + } + + async fn closed(&self) { + if self.notify_drop.acquire().await.is_ok() { + error!("should never have gotten a permit"); + } + } + + fn is_closed(&self) -> bool { + self.notify_drop.is_closed() + } +} + +pub struct Dealer { + shared: Arc, + handle: TimeoutOnDrop<()>, +} + +impl Dealer { + pub fn add_handler(&self, uri: &str, handler: H) -> Result<(), Error> + where + H: RequestHandler, + { + add_handler(&mut self.shared.request_handlers.lock(), uri, handler) + } + + pub fn remove_handler(&self, uri: &str) -> Option> { + remove_handler(&mut self.shared.request_handlers.lock(), uri) + } + + pub fn subscribe(&self, uris: &[&str]) -> Result { + subscribe(&mut self.shared.message_handlers.lock(), uris) + } + + pub async fn close(mut self) { + debug!("closing dealer"); + + self.shared.notify_drop.close(); + + if let Some(handle) = self.handle.take() { + if let Err(e) = CancelOnDrop(handle).await { + error!("error aborting dealer operations: {}", e); + } + } + } +} + +/// Initializes a connection and returns futures that will finish when the connection is closed/lost. +async fn connect( + address: &Url, + proxy: Option<&Url>, + shared: &Arc, +) -> WsResult<(JoinHandle<()>, JoinHandle<()>)> { + let host = address + .host_str() + .ok_or(WsError::Url(UrlError::NoHostName))?; + + let default_port = match address.scheme() { + "ws" => 80, + "wss" => 443, + _ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme)), + }; + + let port = address.port().unwrap_or(default_port); + + let stream = socket::connect(host, port, proxy).await?; + + let (mut ws_tx, ws_rx) = tokio_tungstenite::client_async_tls(address, stream) + .await? + .0 + .split(); + + let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); + + // Spawn a task that will forward messages from the channel to the websocket. + let send_task = { + let shared = Arc::clone(shared); + + tokio::spawn(async move { + let result = loop { + select! { + biased; + () = shared.closed() => { + break Ok(None); + } + msg = send_rx.recv() => { + if let Some(msg) = msg { + // New message arrived through channel + if let WsMessage::Close(close_frame) = msg { + break Ok(close_frame); + } + + if let Err(e) = ws_tx.feed(msg).await { + break Err(e); + } + } else { + break Ok(None); + } + }, + e = keep_flushing(&mut ws_tx) => { + break Err(e) + } + else => (), + } + }; + + send_rx.close(); + + // I don't trust in tokio_tungstenite's implementation of Sink::close. + let result = match result { + Ok(close_frame) => ws_tx.send(WsMessage::Close(close_frame)).await, + Err(WsError::AlreadyClosed) | Err(WsError::ConnectionClosed) => ws_tx.flush().await, + Err(e) => { + warn!("Dealer finished with an error: {}", e); + ws_tx.send(WsMessage::Close(None)).await + } + }; + + if let Err(e) = result { + warn!("Error while closing websocket: {}", e); + } + + debug!("Dropping send task"); + }) + }; + + let shared = Arc::clone(shared); + + // A task that receives messages from the web socket. + let receive_task = tokio::spawn(async { + let pong_received = AtomicBool::new(true); + let send_tx = send_tx; + let shared = shared; + + let receive_task = async { + let mut ws_rx = ws_rx; + + loop { + match ws_rx.next().await { + Some(Ok(msg)) => match msg { + WsMessage::Text(t) => match serde_json::from_str(&t) { + Ok(m) => shared.dispatch(m, &send_tx), + Err(e) => info!("Received invalid message: {}", e), + }, + WsMessage::Binary(_) => { + info!("Received invalid binary message"); + } + WsMessage::Pong(_) => { + debug!("Received pong"); + pong_received.store(true, atomic::Ordering::Relaxed); + } + _ => (), // tungstenite handles Close and Ping automatically + }, + Some(Err(e)) => { + warn!("Websocket connection failed: {}", e); + break; + } + None => { + debug!("Websocket connection closed."); + break; + } + } + } + }; + + // Sends pings and checks whether a pong comes back. + let ping_task = async { + use tokio::time::{interval, sleep}; + + let mut timer = interval(PING_INTERVAL); + + loop { + timer.tick().await; + + pong_received.store(false, atomic::Ordering::Relaxed); + if send_tx.send(WsMessage::Ping(vec![])).is_err() { + // The sender is closed. + break; + } + + debug!("Sent ping"); + + sleep(PING_TIMEOUT).await; + + if !pong_received.load(atomic::Ordering::SeqCst) { + // No response + warn!("Websocket peer does not respond."); + break; + } + } + }; + + // Exit this task as soon as one our subtasks fails. + // In both cases the connection is probably lost. + select! { + () = ping_task => (), + () = receive_task => () + } + + // Try to take send_task down with us, in case it's still alive. + let _ = send_tx.send(WsMessage::Close(None)); + + debug!("Dropping receive task"); + }); + + Ok((send_task, receive_task)) +} + +/// The main background task for `Dealer`, which coordinates reconnecting. +async fn run( + shared: Arc, + initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>, + mut get_url: F, + proxy: Option, +) where + Fut: Future + Send + 'static, + F: (FnMut() -> Fut) + Send + 'static, +{ + let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT)); + + let mut tasks = if let Some((s, r)) = initial_tasks { + (init_task(s), init_task(r)) + } else { + (None, None) + }; + + while !shared.is_closed() { + match &mut tasks { + (Some(t0), Some(t1)) => { + select! { + () = shared.closed() => break, + r = t0 => { + if let Err(e) = r { + error!("timeout on task 0: {}", e); + } + tasks.0.take(); + }, + r = t1 => { + if let Err(e) = r { + error!("timeout on task 1: {}", e); + } + tasks.1.take(); + } + } + } + _ => { + let url = select! { + () = shared.closed() => { + break + }, + e = get_url() => e + }; + + match connect(&url, proxy.as_ref(), &shared).await { + Ok((s, r)) => tasks = (init_task(s), init_task(r)), + Err(e) => { + error!("Error while connecting: {}", e); + tokio::time::sleep(RECONNECT_INTERVAL).await; + } + } + } + } + } + + let tasks = tasks.0.into_iter().chain(tasks.1); + + let _ = join_all(tasks).await; +} diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs new file mode 100644 index 00000000..9e62a2e5 --- /dev/null +++ b/core/src/dealer/protocol.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +pub type JsonValue = serde_json::Value; +pub type JsonObject = serde_json::Map; + +#[derive(Clone, Debug, Deserialize)] +pub struct Payload { + pub message_id: i32, + pub sent_by_device_id: String, + pub command: JsonObject, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Request { + #[serde(default)] + pub headers: HashMap, + pub message_ident: String, + pub key: String, + pub payload: Payload, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Message { + #[serde(default)] + pub headers: HashMap, + pub method: Option, + #[serde(default)] + pub payloads: Vec, + pub uri: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum MessageOrRequest { + Message(Message), + Request(Request), +} diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 00000000..d032bd2a --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,447 @@ +use std::{error, fmt, num::ParseIntError, str::Utf8Error, string::FromUtf8Error}; + +use base64::DecodeError; +use http::{ + header::{InvalidHeaderName, InvalidHeaderValue, ToStrError}, + method::InvalidMethod, + status::InvalidStatusCode, + uri::{InvalidUri, InvalidUriParts}, +}; +use protobuf::ProtobufError; +use thiserror::Error; +use tokio::sync::{mpsc::error::SendError, oneshot::error::RecvError}; +use url::ParseError; + +#[cfg(feature = "with-dns-sd")] +use dns_sd::DNSError; + +#[derive(Debug)] +pub struct Error { + pub kind: ErrorKind, + pub error: Box, +} + +#[derive(Clone, Copy, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd)] +pub enum ErrorKind { + #[error("The operation was cancelled by the caller")] + Cancelled = 1, + + #[error("Unknown error")] + Unknown = 2, + + #[error("Client specified an invalid argument")] + InvalidArgument = 3, + + #[error("Deadline expired before operation could complete")] + DeadlineExceeded = 4, + + #[error("Requested entity was not found")] + NotFound = 5, + + #[error("Attempt to create entity that already exists")] + AlreadyExists = 6, + + #[error("Permission denied")] + PermissionDenied = 7, + + #[error("No valid authentication credentials")] + Unauthenticated = 16, + + #[error("Resource has been exhausted")] + ResourceExhausted = 8, + + #[error("Invalid state")] + FailedPrecondition = 9, + + #[error("Operation aborted")] + Aborted = 10, + + #[error("Operation attempted past the valid range")] + OutOfRange = 11, + + #[error("Not implemented")] + Unimplemented = 12, + + #[error("Internal error")] + Internal = 13, + + #[error("Service unavailable")] + Unavailable = 14, + + #[error("Unrecoverable data loss or corruption")] + DataLoss = 15, + + #[error("Operation must not be used")] + DoNotUse = -1, +} + +#[derive(Debug, Error)] +struct ErrorMessage(String); + +impl fmt::Display for ErrorMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Error { + pub fn new(kind: ErrorKind, error: E) -> Error + where + E: Into>, + { + Self { + kind, + error: error.into(), + } + } + + pub fn aborted(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Aborted, + error: error.into(), + } + } + + pub fn already_exists(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::AlreadyExists, + error: error.into(), + } + } + + pub fn cancelled(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Cancelled, + error: error.into(), + } + } + + pub fn data_loss(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::DataLoss, + error: error.into(), + } + } + + pub fn deadline_exceeded(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::DeadlineExceeded, + error: error.into(), + } + } + + pub fn do_not_use(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::DoNotUse, + error: error.into(), + } + } + + pub fn failed_precondition(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::FailedPrecondition, + error: error.into(), + } + } + + pub fn internal(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Internal, + error: error.into(), + } + } + + pub fn invalid_argument(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::InvalidArgument, + error: error.into(), + } + } + + pub fn not_found(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::NotFound, + error: error.into(), + } + } + + pub fn out_of_range(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::OutOfRange, + error: error.into(), + } + } + + pub fn permission_denied(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::PermissionDenied, + error: error.into(), + } + } + + pub fn resource_exhausted(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::ResourceExhausted, + error: error.into(), + } + } + + pub fn unauthenticated(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Unauthenticated, + error: error.into(), + } + } + + pub fn unavailable(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Unavailable, + error: error.into(), + } + } + + pub fn unimplemented(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Unimplemented, + error: error.into(), + } + } + + pub fn unknown(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Unknown, + error: error.into(), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.error.source() + } +} + +impl fmt::Display for Error { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(fmt, "{} {{ ", self.kind)?; + self.error.fmt(fmt)?; + write!(fmt, " }}") + } +} + +impl From for Error { + fn from(err: DecodeError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +#[cfg(feature = "with-dns-sd")] +impl From for Error { + fn from(err: DNSError) -> Self { + Self::new(ErrorKind::Unavailable, err) + } +} + +impl From for Error { + fn from(err: http::Error) -> Self { + if err.is::() + || err.is::() + || err.is::() + || err.is::() + || err.is::() + { + return Self::new(ErrorKind::InvalidArgument, err); + } + + if err.is::() { + return Self::new(ErrorKind::FailedPrecondition, err); + } + + Self::new(ErrorKind::Unknown, err) + } +} + +impl From for Error { + fn from(err: hyper::Error) -> Self { + if err.is_parse() || err.is_parse_too_large() || err.is_parse_status() || err.is_user() { + return Self::new(ErrorKind::Internal, err); + } + + if err.is_canceled() { + return Self::new(ErrorKind::Cancelled, err); + } + + if err.is_connect() { + return Self::new(ErrorKind::Unavailable, err); + } + + if err.is_incomplete_message() { + return Self::new(ErrorKind::DataLoss, err); + } + + if err.is_body_write_aborted() || err.is_closed() { + return Self::new(ErrorKind::Aborted, err); + } + + if err.is_timeout() { + return Self::new(ErrorKind::DeadlineExceeded, err); + } + + Self::new(ErrorKind::Unknown, err) + } +} + +impl From for Error { + fn from(err: quick_xml::Error) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: serde_json::Error) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + use std::io::ErrorKind as IoErrorKind; + match err.kind() { + IoErrorKind::NotFound => Self::new(ErrorKind::NotFound, err), + IoErrorKind::PermissionDenied => Self::new(ErrorKind::PermissionDenied, err), + IoErrorKind::AddrInUse | IoErrorKind::AlreadyExists => { + Self::new(ErrorKind::AlreadyExists, err) + } + IoErrorKind::AddrNotAvailable + | IoErrorKind::ConnectionRefused + | IoErrorKind::NotConnected => Self::new(ErrorKind::Unavailable, err), + IoErrorKind::BrokenPipe + | IoErrorKind::ConnectionReset + | IoErrorKind::ConnectionAborted => Self::new(ErrorKind::Aborted, err), + IoErrorKind::Interrupted | IoErrorKind::WouldBlock => { + Self::new(ErrorKind::Cancelled, err) + } + IoErrorKind::InvalidData | IoErrorKind::UnexpectedEof => { + Self::new(ErrorKind::FailedPrecondition, err) + } + IoErrorKind::TimedOut => Self::new(ErrorKind::DeadlineExceeded, err), + IoErrorKind::InvalidInput => Self::new(ErrorKind::InvalidArgument, err), + IoErrorKind::WriteZero => Self::new(ErrorKind::ResourceExhausted, err), + _ => Self::new(ErrorKind::Unknown, err), + } + } +} + +impl From for Error { + fn from(err: FromUtf8Error) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: InvalidHeaderValue) -> Self { + Self::new(ErrorKind::InvalidArgument, err) + } +} + +impl From for Error { + fn from(err: InvalidUri) -> Self { + Self::new(ErrorKind::InvalidArgument, err) + } +} + +impl From for Error { + fn from(err: ParseError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: ParseIntError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: ProtobufError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: RecvError) -> Self { + Self::new(ErrorKind::Internal, err) + } +} + +impl From> for Error { + fn from(err: SendError) -> Self { + Self { + kind: ErrorKind::Internal, + error: ErrorMessage(err.to_string()).into(), + } + } +} + +impl From for Error { + fn from(err: ToStrError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: Utf8Error) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} diff --git a/core/src/file_id.rs b/core/src/file_id.rs new file mode 100644 index 00000000..5422c428 --- /dev/null +++ b/core/src/file_id.rs @@ -0,0 +1,56 @@ +use std::fmt; + +use librespot_protocol as protocol; + +use crate::{spotify_id::to_base16, Error}; + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FileId(pub [u8; 20]); + +impl FileId { + pub fn from_raw(src: &[u8]) -> FileId { + let mut dst = [0u8; 20]; + dst.clone_from_slice(src); + FileId(dst) + } + + #[allow(clippy::wrong_self_convention)] + pub fn to_base16(&self) -> Result { + to_base16(&self.0, &mut [0u8; 40]) + } +} + +impl fmt::Debug for FileId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_tuple("FileId").field(&self.to_base16()).finish() + } +} + +impl fmt::Display for FileId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.to_base16().unwrap_or_default()) + } +} + +impl From<&[u8]> for FileId { + fn from(src: &[u8]) -> Self { + Self::from_raw(src) + } +} +impl From<&protocol::metadata::Image> for FileId { + fn from(image: &protocol::metadata::Image) -> Self { + Self::from(image.get_file_id()) + } +} + +impl From<&protocol::metadata::AudioFile> for FileId { + fn from(file: &protocol::metadata::AudioFile) -> Self { + Self::from(file.get_file_id()) + } +} + +impl From<&protocol::metadata::VideoFile> for FileId { + fn from(video: &protocol::metadata::VideoFile) -> Self { + Self::from(video.get_file_id()) + } +} diff --git a/core/src/http_client.rs b/core/src/http_client.rs new file mode 100644 index 00000000..ed7eac6d --- /dev/null +++ b/core/src/http_client.rs @@ -0,0 +1,164 @@ +use std::env::consts::OS; + +use bytes::Bytes; +use futures_util::{future::IntoStream, FutureExt}; +use http::header::HeaderValue; +use hyper::{ + client::{HttpConnector, ResponseFuture}, + header::USER_AGENT, + Body, Client, Request, Response, StatusCode, +}; +use hyper_proxy::{Intercept, Proxy, ProxyConnector}; +use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; +use thiserror::Error; +use url::Url; + +use crate::{ + version::{FALLBACK_USER_AGENT, SPOTIFY_MOBILE_VERSION, SPOTIFY_VERSION, VERSION_STRING}, + Error, +}; + +#[derive(Debug, Error)] +pub enum HttpClientError { + #[error("Response status code: {0}")] + StatusCode(hyper::StatusCode), +} + +impl From for Error { + fn from(err: HttpClientError) -> Self { + match err { + HttpClientError::StatusCode(code) => { + // not exhaustive, but what reasonably could be expected + match code { + StatusCode::GATEWAY_TIMEOUT | StatusCode::REQUEST_TIMEOUT => { + Error::deadline_exceeded(err) + } + StatusCode::GONE + | StatusCode::NOT_FOUND + | StatusCode::MOVED_PERMANENTLY + | StatusCode::PERMANENT_REDIRECT + | StatusCode::TEMPORARY_REDIRECT => Error::not_found(err), + StatusCode::FORBIDDEN | StatusCode::PAYMENT_REQUIRED => { + Error::permission_denied(err) + } + StatusCode::NETWORK_AUTHENTICATION_REQUIRED + | StatusCode::PROXY_AUTHENTICATION_REQUIRED + | StatusCode::UNAUTHORIZED => Error::unauthenticated(err), + StatusCode::EXPECTATION_FAILED + | StatusCode::PRECONDITION_FAILED + | StatusCode::PRECONDITION_REQUIRED => Error::failed_precondition(err), + StatusCode::RANGE_NOT_SATISFIABLE => Error::out_of_range(err), + StatusCode::INTERNAL_SERVER_ERROR + | StatusCode::MISDIRECTED_REQUEST + | StatusCode::SERVICE_UNAVAILABLE + | StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS => Error::unavailable(err), + StatusCode::BAD_REQUEST + | StatusCode::HTTP_VERSION_NOT_SUPPORTED + | StatusCode::LENGTH_REQUIRED + | StatusCode::METHOD_NOT_ALLOWED + | StatusCode::NOT_ACCEPTABLE + | StatusCode::PAYLOAD_TOO_LARGE + | StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + | StatusCode::UNSUPPORTED_MEDIA_TYPE + | StatusCode::URI_TOO_LONG => Error::invalid_argument(err), + StatusCode::TOO_MANY_REQUESTS => Error::resource_exhausted(err), + StatusCode::NOT_IMPLEMENTED => Error::unimplemented(err), + _ => Error::unknown(err), + } + } + } + } +} + +#[derive(Clone)] +pub struct HttpClient { + user_agent: HeaderValue, + proxy: Option, + https_connector: HttpsConnector, +} + +impl HttpClient { + pub fn new(proxy: Option<&Url>) -> Self { + let spotify_version = match OS { + "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), + _ => SPOTIFY_VERSION.to_string(), + }; + + let spotify_platform = match OS { + "android" => "Android/31", + "ios" => "iOS/15.2.1", + "macos" => "OSX/0", + "windows" => "Win32/0", + _ => "Linux/0", + }; + + let user_agent_str = &format!( + "Spotify/{} {} ({})", + spotify_version, spotify_platform, VERSION_STRING + ); + + let user_agent = HeaderValue::from_str(user_agent_str).unwrap_or_else(|err| { + error!("Invalid user agent <{}>: {}", user_agent_str, err); + HeaderValue::from_static(FALLBACK_USER_AGENT) + }); + + // configuring TLS is expensive and should be done once per process + let https_connector = HttpsConnectorBuilder::new() + .with_native_roots() + .https_or_http() + .enable_http1() + .enable_http2() + .build(); + + Self { + user_agent, + proxy: proxy.cloned(), + https_connector, + } + } + + pub async fn request(&self, req: Request) -> Result, Error> { + debug!("Requesting {}", req.uri().to_string()); + + let request = self.request_fut(req)?; + let response = request.await; + + if let Ok(response) = &response { + let code = response.status(); + if code != StatusCode::OK { + return Err(HttpClientError::StatusCode(code).into()); + } + } + + Ok(response?) + } + + pub async fn request_body(&self, req: Request) -> Result { + let response = self.request(req).await?; + Ok(hyper::body::to_bytes(response.into_body()).await?) + } + + pub fn request_stream(&self, req: Request) -> Result, Error> { + Ok(self.request_fut(req)?.into_stream()) + } + + pub fn request_fut(&self, mut req: Request) -> Result { + let headers_mut = req.headers_mut(); + headers_mut.insert(USER_AGENT, self.user_agent.clone()); + + let request = if let Some(url) = &self.proxy { + let proxy_uri = url.to_string().parse()?; + let proxy = Proxy::new(Intercept::All, proxy_uri); + let proxy_connector = ProxyConnector::from_proxy(self.https_connector.clone(), proxy)?; + + Client::builder().build(proxy_connector).request(req) + } else { + Client::builder() + .http2_adaptive_window(true) + .build(self.https_connector.clone()) + .request(req) + }; + + Ok(request) + } +} diff --git a/core/src/keymaster.rs b/core/src/keymaster.rs deleted file mode 100644 index 8c3c00a2..00000000 --- a/core/src/keymaster.rs +++ /dev/null @@ -1,26 +0,0 @@ -use serde::Deserialize; - -use crate::{mercury::MercuryError, session::Session}; - -#[derive(Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Token { - pub access_token: String, - pub expires_in: u32, - pub token_type: String, - pub scope: Vec, -} - -pub async fn get_token( - session: &Session, - client_id: &str, - scopes: &str, -) -> Result { - let url = format!( - "hm://keymaster/token/authenticated?client_id={}&scope={}", - client_id, scopes - ); - let response = session.mercury().get(url).await?; - let data = response.payload.first().expect("Empty payload"); - serde_json::from_slice(data.as_ref()).map_err(|_| MercuryError) -} diff --git a/core/src/lib.rs b/core/src/lib.rs index 9afb99a3..a0f180ca 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,27 +1,43 @@ -#![allow(clippy::unused_io_amount)] - #[macro_use] extern crate log; +extern crate num_derive; use librespot_protocol as protocol; #[macro_use] mod component; -mod apresolve; +pub mod apresolve; pub mod audio_key; pub mod authentication; pub mod cache; +pub mod cdn_url; pub mod channel; pub mod config; mod connection; +pub mod date; +#[allow(dead_code)] +mod dealer; #[doc(hidden)] pub mod diffie_hellman; -pub mod keymaster; +pub mod error; +pub mod file_id; +mod http_client; pub mod mercury; +pub mod packet; mod proxytunnel; pub mod session; +mod socket; +#[allow(dead_code)] +pub mod spclient; pub mod spotify_id; +pub mod token; #[doc(hidden)] pub mod util; pub mod version; + +pub use config::SessionConfig; +pub use error::Error; +pub use file_id::FileId; +pub use session::Session; +pub use spotify_id::SpotifyId; diff --git a/core/src/mercury/mod.rs b/core/src/mercury/mod.rs index 57650087..44e8de9c 100644 --- a/core/src/mercury/mod.rs +++ b/core/src/mercury/mod.rs @@ -1,9 +1,10 @@ -use std::collections::HashMap; -use std::future::Future; -use std::mem; -use std::pin::Pin; -use std::task::Context; -use std::task::Poll; +use std::{ + collections::HashMap, + future::Future, + mem, + pin::Pin, + task::{Context, Poll}, +}; use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; @@ -11,8 +12,7 @@ use futures_util::FutureExt; use protobuf::Message; use tokio::sync::{mpsc, oneshot}; -use crate::protocol; -use crate::util::SeqGenerator; +use crate::{packet::PacketType, protocol, util::SeqGenerator, Error}; mod types; pub use self::types::*; @@ -32,18 +32,18 @@ component! { pub struct MercuryPending { parts: Vec>, partial: Option>, - callback: Option>>, + callback: Option>>, } pub struct MercuryFuture { - receiver: oneshot::Receiver>, + receiver: oneshot::Receiver>, } impl Future for MercuryFuture { - type Output = Result; + type Output = Result; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - self.receiver.poll_unpin(cx).map_err(|_| MercuryError)? + self.receiver.poll_unpin(cx)? } } @@ -54,7 +54,7 @@ impl MercuryManager { seq } - fn request(&self, req: MercuryRequest) -> MercuryFuture { + fn request(&self, req: MercuryRequest) -> Result, Error> { let (tx, rx) = oneshot::channel(); let pending = MercuryPending { @@ -71,13 +71,13 @@ impl MercuryManager { }); let cmd = req.method.command(); - let data = req.encode(&seq); + let data = req.encode(&seq)?; - self.session().send_packet(cmd, data); - MercuryFuture { receiver: rx } + self.session().send_packet(cmd, data)?; + Ok(MercuryFuture { receiver: rx }) } - pub fn get>(&self, uri: T) -> MercuryFuture { + pub fn get>(&self, uri: T) -> Result, Error> { self.request(MercuryRequest { method: MercuryMethod::Get, uri: uri.into(), @@ -86,7 +86,11 @@ impl MercuryManager { }) } - pub fn send>(&self, uri: T, data: Vec) -> MercuryFuture { + pub fn send>( + &self, + uri: T, + data: Vec, + ) -> Result, Error> { self.request(MercuryRequest { method: MercuryMethod::Send, uri: uri.into(), @@ -102,7 +106,7 @@ impl MercuryManager { pub fn subscribe>( &self, uri: T, - ) -> impl Future, MercuryError>> + 'static + ) -> impl Future, Error>> + 'static { let uri = uri.into(); let request = self.request(MercuryRequest { @@ -114,7 +118,7 @@ impl MercuryManager { let manager = self.clone(); async move { - let response = request.await?; + let response = request?.await?; let (tx, rx) = mpsc::unbounded_channel(); @@ -124,13 +128,18 @@ impl MercuryManager { if !response.payload.is_empty() { // Old subscription protocol, watch the provided list of URIs for sub in response.payload { - let mut sub = - protocol::pubsub::Subscription::parse_from_bytes(&sub).unwrap(); - let sub_uri = sub.take_uri(); + match protocol::pubsub::Subscription::parse_from_bytes(&sub) { + Ok(mut sub) => { + let sub_uri = sub.take_uri(); - debug!("subscribed sub_uri={}", sub_uri); + debug!("subscribed sub_uri={}", sub_uri); - inner.subscriptions.push((sub_uri, tx.clone())); + inner.subscriptions.push((sub_uri, tx.clone())); + } + Err(e) => { + error!("could not subscribe to {}: {}", uri, e); + } + } } } else { // New subscription protocol, watch the requested URI @@ -143,7 +152,28 @@ impl MercuryManager { } } - pub(crate) fn dispatch(&self, cmd: u8, mut data: Bytes) { + pub fn listen_for>( + &self, + uri: T, + ) -> impl Future> + 'static { + let uri = uri.into(); + + let manager = self.clone(); + async move { + let (tx, rx) = mpsc::unbounded_channel(); + + manager.lock(move |inner| { + if !inner.invalid { + debug!("listening to uri={}", uri); + inner.subscriptions.push((uri, tx)); + } + }); + + rx + } + } + + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { let seq_len = BigEndian::read_u16(data.split_to(2).as_ref()) as usize; let seq = data.split_to(seq_len).as_ref().to_owned(); @@ -154,14 +184,17 @@ impl MercuryManager { let mut pending = match pending { Some(pending) => pending, - None if cmd == 0xb5 => MercuryPending { - parts: Vec::new(), - partial: None, - callback: None, - }, None => { - warn!("Ignore seq {:?} cmd {:x}", seq, cmd); - return; + if let PacketType::MercuryEvent = cmd { + MercuryPending { + parts: Vec::new(), + partial: None, + callback: None, + } + } else { + warn!("Ignore seq {:?} cmd {:x}", seq, cmd as u8); + return Err(MercuryError::Command(cmd).into()); + } } }; @@ -180,10 +213,12 @@ impl MercuryManager { } if flags == 0x1 { - self.complete_request(cmd, pending); + self.complete_request(cmd, pending)?; } else { self.lock(move |inner| inner.pending.insert(seq, pending)); } + + Ok(()) } fn parse_part(data: &mut Bytes) -> Vec { @@ -191,9 +226,9 @@ impl MercuryManager { data.split_to(size).as_ref().to_owned() } - fn complete_request(&self, cmd: u8, mut pending: MercuryPending) { + fn complete_request(&self, cmd: PacketType, mut pending: MercuryPending) -> Result<(), Error> { let header_data = pending.parts.remove(0); - let header = protocol::mercury::Header::parse_from_bytes(&header_data).unwrap(); + let header = protocol::mercury::Header::parse_from_bytes(&header_data)?; let response = MercuryResponse { uri: header.get_uri().to_string(), @@ -201,29 +236,33 @@ impl MercuryManager { payload: pending.parts, }; - if response.status_code >= 500 { - panic!("Spotify servers returned an error. Restart librespot."); - } else if response.status_code >= 400 { - warn!("error {} for uri {}", response.status_code, &response.uri); + let status_code = response.status_code; + if status_code >= 500 { + error!("error {} for uri {}", status_code, &response.uri); + Err(MercuryError::Response(response).into()) + } else if status_code >= 400 { + error!("error {} for uri {}", status_code, &response.uri); if let Some(cb) = pending.callback { - let _ = cb.send(Err(MercuryError)); + cb.send(Err(MercuryError::Response(response.clone()).into())) + .map_err(|_| MercuryError::Channel)?; } - } else if cmd == 0xb5 { + Err(MercuryError::Response(response).into()) + } else if let PacketType::MercuryEvent = cmd { + // TODO: This is just a workaround to make utf-8 encoded usernames work. + // A better solution would be to use an uri struct and urlencode it directly + // before sending while saving the subscription under its unencoded form. + let mut uri_split = response.uri.split('/'); + + let encoded_uri = std::iter::once(uri_split.next().unwrap_or_default().to_string()) + .chain(uri_split.map(|component| { + form_urlencoded::byte_serialize(component.as_bytes()).collect::() + })) + .collect::>() + .join("/"); + + let mut found = false; + self.lock(|inner| { - let mut found = false; - - // TODO: This is just a workaround to make utf-8 encoded usernames work. - // A better solution would be to use an uri struct and urlencode it directly - // before sending while saving the subscription under its unencoded form. - let mut uri_split = response.uri.split('/'); - - let encoded_uri = std::iter::once(uri_split.next().unwrap().to_string()) - .chain(uri_split.map(|component| { - form_urlencoded::byte_serialize(component.as_bytes()).collect::() - })) - .collect::>() - .join("/"); - inner.subscriptions.retain(|&(ref prefix, ref sub)| { if encoded_uri.starts_with(prefix) { found = true; @@ -236,13 +275,21 @@ impl MercuryManager { true } }); + }); - if !found { - debug!("unknown subscription uri={}", response.uri); - } - }) + if !found { + debug!("unknown subscription uri={}", &response.uri); + trace!("response pushed over Mercury: {:?}", response); + Err(MercuryError::Response(response).into()) + } else { + Ok(()) + } } else if let Some(cb) = pending.callback { - let _ = cb.send(Ok(response)); + cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?; + Ok(()) + } else { + error!("can't handle Mercury response: {:?}", response); + Err(MercuryError::Response(response).into()) } } diff --git a/core/src/mercury/sender.rs b/core/src/mercury/sender.rs index 268554d9..31409e88 100644 --- a/core/src/mercury/sender.rs +++ b/core/src/mercury/sender.rs @@ -1,6 +1,8 @@ use std::collections::VecDeque; -use super::*; +use super::{MercuryFuture, MercuryManager, MercuryResponse}; + +use crate::Error; pub struct MercurySender { mercury: MercuryManager, @@ -23,12 +25,13 @@ impl MercurySender { self.buffered_future.is_none() && self.pending.is_empty() } - pub fn send(&mut self, item: Vec) { - let task = self.mercury.send(self.uri.clone(), item); + pub fn send(&mut self, item: Vec) -> Result<(), Error> { + let task = self.mercury.send(self.uri.clone(), item)?; self.pending.push_back(task); + Ok(()) } - pub async fn flush(&mut self) -> Result<(), MercuryError> { + pub async fn flush(&mut self) -> Result<(), Error> { if self.buffered_future.is_none() { self.buffered_future = self.pending.pop_front(); } diff --git a/core/src/mercury/types.rs b/core/src/mercury/types.rs index 402a954c..9c7593fe 100644 --- a/core/src/mercury/types.rs +++ b/core/src/mercury/types.rs @@ -1,8 +1,10 @@ -use byteorder::{BigEndian, WriteBytesExt}; -use protobuf::Message; use std::io::Write; -use crate::protocol; +use byteorder::{BigEndian, WriteBytesExt}; +use protobuf::Message; +use thiserror::Error; + +use crate::{packet::PacketType, protocol, Error}; #[derive(Debug, PartialEq, Eq)] pub enum MercuryMethod { @@ -27,8 +29,25 @@ pub struct MercuryResponse { pub payload: Vec>, } -#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] -pub struct MercuryError; +#[derive(Debug, Error)] +pub enum MercuryError { + #[error("callback receiver was disconnected")] + Channel, + #[error("error handling packet type: {0:?}")] + Command(PacketType), + #[error("error handling Mercury response: {0:?}")] + Response(MercuryResponse), +} + +impl From for Error { + fn from(err: MercuryError) -> Self { + match err { + MercuryError::Channel => Error::aborted(err), + MercuryError::Command(_) => Error::unimplemented(err), + MercuryError::Response(_) => Error::unavailable(err), + } + } +} impl ToString for MercuryMethod { fn to_string(&self) -> String { @@ -43,24 +62,23 @@ impl ToString for MercuryMethod { } impl MercuryMethod { - pub fn command(&self) -> u8 { + pub fn command(&self) -> PacketType { + use PacketType::*; match *self { - MercuryMethod::Get | MercuryMethod::Send => 0xb2, - MercuryMethod::Sub => 0xb3, - MercuryMethod::Unsub => 0xb4, + MercuryMethod::Get | MercuryMethod::Send => MercuryReq, + MercuryMethod::Sub => MercurySub, + MercuryMethod::Unsub => MercuryUnsub, } } } impl MercuryRequest { - pub fn encode(&self, seq: &[u8]) -> Vec { + pub fn encode(&self, seq: &[u8]) -> Result, Error> { let mut packet = Vec::new(); - packet.write_u16::(seq.len() as u16).unwrap(); - packet.write_all(seq).unwrap(); - packet.write_u8(1).unwrap(); // Flags: FINAL - packet - .write_u16::(1 + self.payload.len() as u16) - .unwrap(); // Part count + packet.write_u16::(seq.len() as u16)?; + packet.write_all(seq)?; + packet.write_u8(1)?; // Flags: FINAL + packet.write_u16::(1 + self.payload.len() as u16)?; // Part count let mut header = protocol::mercury::Header::new(); header.set_uri(self.uri.clone()); @@ -70,16 +88,14 @@ impl MercuryRequest { header.set_content_type(content_type.clone()); } - packet - .write_u16::(header.compute_size() as u16) - .unwrap(); - header.write_to_writer(&mut packet).unwrap(); + packet.write_u16::(header.compute_size() as u16)?; + header.write_to_writer(&mut packet)?; for p in &self.payload { - packet.write_u16::(p.len() as u16).unwrap(); - packet.write(p).unwrap(); + packet.write_u16::(p.len() as u16)?; + packet.write_all(p)?; } - packet + Ok(packet) } } diff --git a/core/src/packet.rs b/core/src/packet.rs new file mode 100644 index 00000000..2f50d158 --- /dev/null +++ b/core/src/packet.rs @@ -0,0 +1,41 @@ +// Ported from librespot-java. Relicensed under MIT with permission. + +use num_derive::{FromPrimitive, ToPrimitive}; + +#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)] +pub enum PacketType { + SecretBlock = 0x02, + Ping = 0x04, + StreamChunk = 0x08, + StreamChunkRes = 0x09, + ChannelError = 0x0a, + ChannelAbort = 0x0b, + RequestKey = 0x0c, + AesKey = 0x0d, + AesKeyError = 0x0e, + Image = 0x19, + CountryCode = 0x1b, + Pong = 0x49, + PongAck = 0x4a, + Pause = 0x4b, + ProductInfo = 0x50, + LegacyWelcome = 0x69, + LicenseVersion = 0x76, + Login = 0xab, + APWelcome = 0xac, + AuthFailure = 0xad, + MercuryReq = 0xb2, + MercurySub = 0xb3, + MercuryUnsub = 0xb4, + MercuryEvent = 0xb5, + TrackEndedTime = 0x82, + UnknownDataAllZeros = 0x1f, + PreferredLocale = 0x74, + Unknown0x0f = 0x0f, + Unknown0x10 = 0x10, + Unknown0x4f = 0x4f, + + // TODO - occurs when subscribing with an empty URI. Maybe a MercuryError? + // Payload: b"\0\x08\0\0\0\0\0\0\0\0\x01\0\x01\0\x03 \xb0\x06" + Unknown0xb6 = 0xb6, +} diff --git a/core/src/session.rs b/core/src/session.rs index 6c8bf93f..11f2cf8a 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -1,29 +1,41 @@ -use std::future::Future; -use std::io; -use std::pin::Pin; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, RwLock, Weak}; -use std::task::Context; -use std::task::Poll; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + collections::HashMap, + future::Future, + io, + pin::Pin, + process::exit, + sync::{Arc, Weak}, + task::{Context, Poll}, + time::{SystemTime, UNIX_EPOCH}, +}; use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; use futures_core::TryStream; use futures_util::{future, ready, StreamExt, TryStreamExt}; +use num_traits::FromPrimitive; use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use quick_xml::events::Event; use thiserror::Error; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; -use crate::apresolve::apresolve; -use crate::audio_key::AudioKeyManager; -use crate::authentication::Credentials; -use crate::cache::Cache; -use crate::channel::ChannelManager; -use crate::config::SessionConfig; -use crate::connection::{self, AuthenticationError}; -use crate::mercury::MercuryManager; +use crate::{ + apresolve::ApResolver, + audio_key::AudioKeyManager, + authentication::Credentials, + cache::Cache, + channel::ChannelManager, + config::SessionConfig, + connection::{self, AuthenticationError}, + http_client::HttpClient, + mercury::MercuryManager, + packet::PacketType, + spclient::SpClient, + token::TokenProvider, + Error, +}; #[derive(Debug, Error)] pub enum SessionError { @@ -31,103 +43,119 @@ pub enum SessionError { AuthenticationError(#[from] AuthenticationError), #[error("Cannot create session: {0}")] IoError(#[from] io::Error), + #[error("Session is not connected")] + NotConnected, + #[error("packet {0} unknown")] + Packet(u8), } +impl From for Error { + fn from(err: SessionError) -> Self { + match err { + SessionError::AuthenticationError(_) => Error::unauthenticated(err), + SessionError::IoError(_) => Error::unavailable(err), + SessionError::NotConnected => Error::unavailable(err), + SessionError::Packet(_) => Error::unimplemented(err), + } + } +} + +pub type UserAttributes = HashMap; + +#[derive(Debug, Clone, Default)] +pub struct UserData { + pub country: String, + pub canonical_username: String, + pub attributes: UserAttributes, +} + +#[derive(Debug, Clone, Default)] struct SessionData { - country: String, + client_id: String, + connection_id: String, time_delta: i64, - canonical_username: String, invalid: bool, + user_data: UserData, } struct SessionInternal { config: SessionConfig, data: RwLock, - tx_connection: mpsc::UnboundedSender<(u8, Vec)>, + http_client: HttpClient, + tx_connection: OnceCell)>>, + apresolver: OnceCell, audio_key: OnceCell, channel: OnceCell, mercury: OnceCell, + spclient: OnceCell, + token_provider: OnceCell, cache: Option>, handle: tokio::runtime::Handle, - - session_id: usize, } -static SESSION_COUNTER: AtomicUsize = AtomicUsize::new(0); - #[derive(Clone)] pub struct Session(Arc); impl Session { - pub async fn connect( - config: SessionConfig, - credentials: Credentials, - cache: Option, - store_credentials: bool, - ) -> Result<(Session, Credentials), SessionError> { - let ap = apresolve(config.proxy.as_ref(), config.ap_port).await; + pub fn new(config: SessionConfig, cache: Option) -> Self { + let http_client = HttpClient::new(config.proxy.as_ref()); - info!("Connecting to AP \"{}\"", ap); - let mut conn = connection::connect(ap, config.proxy.as_ref()).await?; + debug!("new Session"); + + let session_data = SessionData { + client_id: config.client_id.clone(), + ..SessionData::default() + }; + + Self(Arc::new(SessionInternal { + config, + data: RwLock::new(session_data), + http_client, + tx_connection: OnceCell::new(), + cache: cache.map(Arc::new), + apresolver: OnceCell::new(), + audio_key: OnceCell::new(), + channel: OnceCell::new(), + mercury: OnceCell::new(), + spclient: OnceCell::new(), + token_provider: OnceCell::new(), + handle: tokio::runtime::Handle::current(), + })) + } + + pub async fn connect( + &self, + credentials: Credentials, + store_credentials: bool, + ) -> Result<(), Error> { + let ap = self.apresolver().resolve("accesspoint").await?; + info!("Connecting to AP \"{}:{}\"", ap.0, ap.1); + let mut transport = connection::connect(&ap.0, ap.1, self.config().proxy.as_ref()).await?; let reusable_credentials = - connection::authenticate(&mut conn, credentials, &config.device_id).await?; + connection::authenticate(&mut transport, credentials, &self.config().device_id).await?; info!("Authenticated as \"{}\" !", reusable_credentials.username); - if let Some(cache) = &cache { + self.set_username(&reusable_credentials.username); + if let Some(cache) = self.cache() { if store_credentials { cache.save_credentials(&reusable_credentials); } } - let session = Session::create( - conn, - config, - cache, - reusable_credentials.username.clone(), - tokio::runtime::Handle::current(), - ); + let (tx_connection, rx_connection) = mpsc::unbounded_channel(); + self.0 + .tx_connection + .set(tx_connection) + .map_err(|_| SessionError::NotConnected)?; - Ok((session, reusable_credentials)) - } - - fn create( - transport: connection::Transport, - config: SessionConfig, - cache: Option, - username: String, - handle: tokio::runtime::Handle, - ) -> Session { let (sink, stream) = transport.split(); - - let (sender_tx, sender_rx) = mpsc::unbounded_channel(); - let session_id = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); - - debug!("new Session[{}]", session_id); - - let session = Session(Arc::new(SessionInternal { - config, - data: RwLock::new(SessionData { - country: String::new(), - canonical_username: username, - invalid: false, - time_delta: 0, - }), - tx_connection: sender_tx, - cache: cache.map(Arc::new), - audio_key: OnceCell::new(), - channel: OnceCell::new(), - mercury: OnceCell::new(), - handle, - session_id, - })); - - let sender_task = UnboundedReceiverStream::new(sender_rx) + let sender_task = UnboundedReceiverStream::new(rx_connection) .map(Ok) .forward(sink); - let receiver_task = DispatchTask(stream, session.weak()); + let receiver_task = DispatchTask(stream, self.weak()); tokio::spawn(async move { let result = future::try_join(sender_task, receiver_task).await; @@ -137,7 +165,13 @@ impl Session { } }); - session + Ok(()) + } + + pub fn apresolver(&self) -> &ApResolver { + self.0 + .apresolver + .get_or_init(|| ApResolver::new(self.weak())) } pub fn audio_key(&self) -> &AudioKeyManager { @@ -152,14 +186,28 @@ impl Session { .get_or_init(|| ChannelManager::new(self.weak())) } + pub fn http_client(&self) -> &HttpClient { + &self.0.http_client + } + pub fn mercury(&self) -> &MercuryManager { self.0 .mercury .get_or_init(|| MercuryManager::new(self.weak())) } + pub fn spclient(&self) -> &SpClient { + self.0.spclient.get_or_init(|| SpClient::new(self.weak())) + } + + pub fn token_provider(&self) -> &TokenProvider { + self.0 + .token_provider + .get_or_init(|| TokenProvider::new(self.weak())) + } + pub fn time_delta(&self) -> i64 { - self.0.data.read().unwrap().time_delta + self.0.data.read().time_delta } pub fn spawn(&self, task: T) @@ -172,17 +220,38 @@ impl Session { fn debug_info(&self) { debug!( - "Session[{}] strong={} weak={}", - self.0.session_id, + "Session strong={} weak={}", Arc::strong_count(&self.0), Arc::weak_count(&self.0) ); } - #[allow(clippy::match_same_arms)] - fn dispatch(&self, cmd: u8, data: Bytes) { - match cmd { - 0x4 => { + fn check_catalogue(attributes: &UserAttributes) { + if let Some(account_type) = attributes.get("type") { + if account_type != "premium" { + error!("librespot does not support {:?} accounts.", account_type); + info!("Please support Spotify and your artists and sign up for a premium account."); + + // TODO: logout instead of exiting + exit(1); + } + } + } + + fn dispatch(&self, cmd: u8, data: Bytes) -> Result<(), Error> { + use PacketType::*; + + let packet_type = FromPrimitive::from_u8(cmd); + let cmd = match packet_type { + Some(cmd) => cmd, + None => { + trace!("Ignoring unknown packet {:x}", cmd); + return Err(SessionError::Packet(cmd).into()); + } + }; + + match packet_type { + Some(Ping) => { let server_timestamp = BigEndian::read_u32(data.as_ref()) as i64; let timestamp = match SystemTime::now().duration_since(UNIX_EPOCH) { Ok(dur) => dur, @@ -190,66 +259,170 @@ impl Session { } .as_secs() as i64; - self.0.data.write().unwrap().time_delta = server_timestamp - timestamp; + self.0.data.write().time_delta = server_timestamp - timestamp; self.debug_info(); - self.send_packet(0x49, vec![0, 0, 0, 0]); + self.send_packet(Pong, vec![0, 0, 0, 0]) } - 0x4a => (), - 0x1b => { - let country = String::from_utf8(data.as_ref().to_owned()).unwrap(); + Some(CountryCode) => { + let country = String::from_utf8(data.as_ref().to_owned())?; info!("Country: {:?}", country); - self.0.data.write().unwrap().country = country; + self.0.data.write().user_data.country = country; + Ok(()) } + Some(StreamChunkRes) | Some(ChannelError) => self.channel().dispatch(cmd, data), + Some(AesKey) | Some(AesKeyError) => self.audio_key().dispatch(cmd, data), + Some(MercuryReq) | Some(MercurySub) | Some(MercuryUnsub) | Some(MercuryEvent) => { + self.mercury().dispatch(cmd, data) + } + Some(ProductInfo) => { + let data = std::str::from_utf8(&data)?; + let mut reader = quick_xml::Reader::from_str(data); - 0x9 | 0xa => self.channel().dispatch(cmd, data), - 0xd | 0xe => self.audio_key().dispatch(cmd, data), - 0xb2..=0xb6 => self.mercury().dispatch(cmd, data), - _ => (), + let mut buf = Vec::new(); + let mut current_element = String::new(); + let mut user_attributes: UserAttributes = HashMap::new(); + + loop { + match reader.read_event(&mut buf) { + Ok(Event::Start(ref element)) => { + current_element = std::str::from_utf8(element.name())?.to_owned() + } + Ok(Event::End(_)) => { + current_element = String::new(); + } + Ok(Event::Text(ref value)) => { + if !current_element.is_empty() { + let _ = user_attributes.insert( + current_element.clone(), + value.unescape_and_decode(&reader)?, + ); + } + } + Ok(Event::Eof) => break, + Ok(_) => (), + Err(e) => error!( + "Error parsing XML at position {}: {:?}", + reader.buffer_position(), + e + ), + } + } + + trace!("Received product info: {:#?}", user_attributes); + Self::check_catalogue(&user_attributes); + + self.0.data.write().user_data.attributes = user_attributes; + Ok(()) + } + Some(PongAck) + | Some(SecretBlock) + | Some(LegacyWelcome) + | Some(UnknownDataAllZeros) + | Some(LicenseVersion) => Ok(()), + _ => { + trace!("Ignoring {:?} packet with data {:#?}", cmd, data); + Err(SessionError::Packet(cmd as u8).into()) + } } } - pub fn send_packet(&self, cmd: u8, data: Vec) { - self.0.tx_connection.send((cmd, data)).unwrap(); + pub fn send_packet(&self, cmd: PacketType, data: Vec) -> Result<(), Error> { + match self.0.tx_connection.get() { + Some(tx) => Ok(tx.send((cmd as u8, data))?), + None => Err(SessionError::NotConnected.into()), + } } pub fn cache(&self) -> Option<&Arc> { self.0.cache.as_ref() } - fn config(&self) -> &SessionConfig { + pub fn config(&self) -> &SessionConfig { &self.0.config } - pub fn username(&self) -> String { - self.0.data.read().unwrap().canonical_username.clone() - } - - pub fn country(&self) -> String { - self.0.data.read().unwrap().country.clone() + // This clones a fairly large struct, so use a specific getter or setter unless + // you need more fields at once, in which case this can spare multiple `read` + // locks. + pub fn user_data(&self) -> UserData { + self.0.data.read().user_data.clone() } pub fn device_id(&self) -> &str { &self.config().device_id } + pub fn client_id(&self) -> String { + self.0.data.read().client_id.clone() + } + + pub fn set_client_id(&self, client_id: &str) { + self.0.data.write().client_id = client_id.to_owned(); + } + + pub fn connection_id(&self) -> String { + self.0.data.read().connection_id.clone() + } + + pub fn set_connection_id(&self, connection_id: &str) { + self.0.data.write().connection_id = connection_id.to_owned(); + } + + pub fn username(&self) -> String { + self.0.data.read().user_data.canonical_username.clone() + } + + pub fn set_username(&self, username: &str) { + self.0.data.write().user_data.canonical_username = username.to_owned(); + } + + pub fn country(&self) -> String { + self.0.data.read().user_data.country.clone() + } + + pub fn set_user_attribute(&self, key: &str, value: &str) -> Option { + let mut dummy_attributes = UserAttributes::new(); + dummy_attributes.insert(key.to_owned(), value.to_owned()); + Self::check_catalogue(&dummy_attributes); + + self.0 + .data + .write() + .user_data + .attributes + .insert(key.to_owned(), value.to_owned()) + } + + pub fn set_user_attributes(&self, attributes: UserAttributes) { + Self::check_catalogue(&attributes); + + self.0.data.write().user_data.attributes.extend(attributes) + } + + pub fn get_user_attribute(&self, key: &str) -> Option { + self.0 + .data + .read() + .user_data + .attributes + .get(key) + .map(Clone::clone) + } + fn weak(&self) -> SessionWeak { SessionWeak(Arc::downgrade(&self.0)) } - pub fn session_id(&self) -> usize { - self.0.session_id - } - pub fn shutdown(&self) { - debug!("Invalidating session[{}]", self.0.session_id); - self.0.data.write().unwrap().invalid = true; + debug!("Invalidating session"); + self.0.data.write().invalid = true; self.mercury().shutdown(); self.channel().shutdown(); } pub fn is_invalid(&self) -> bool { - self.0.data.read().unwrap().invalid + self.0.data.read().invalid } } @@ -262,13 +435,14 @@ impl SessionWeak { } pub(crate) fn upgrade(&self) -> Session { - self.try_upgrade().expect("Session died") + self.try_upgrade() + .expect("session was dropped and so should have this component") } } impl Drop for SessionInternal { fn drop(&mut self) { - debug!("drop Session[{}]", self.session_id); + debug!("drop Session"); } } @@ -303,7 +477,9 @@ where } }; - session.dispatch(cmd, data); + if let Err(e) = session.dispatch(cmd, data) { + error!("could not dispatch command: {}", e); + } } } } diff --git a/core/src/socket.rs b/core/src/socket.rs new file mode 100644 index 00000000..84ac6024 --- /dev/null +++ b/core/src/socket.rs @@ -0,0 +1,34 @@ +use std::{io, net::ToSocketAddrs}; + +use tokio::net::TcpStream; +use url::Url; + +use crate::proxytunnel; + +pub async fn connect(host: &str, port: u16, proxy: Option<&Url>) -> io::Result { + let socket = if let Some(proxy_url) = proxy { + info!("Using proxy \"{}\"", proxy_url); + + let socket_addr = proxy_url.socket_addrs(|| None).and_then(|addrs| { + addrs.into_iter().next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Can't resolve proxy server address", + ) + }) + })?; + let socket = TcpStream::connect(&socket_addr).await?; + + proxytunnel::proxy_connect(socket, host, &port.to_string()).await? + } else { + let socket_addr = (host, port).to_socket_addrs()?.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Can't resolve access point address", + ) + })?; + + TcpStream::connect(&socket_addr).await? + }; + Ok(socket) +} diff --git a/core/src/spclient.rs b/core/src/spclient.rs new file mode 100644 index 00000000..c4f13656 --- /dev/null +++ b/core/src/spclient.rs @@ -0,0 +1,496 @@ +use std::{ + convert::TryInto, + fmt::Write, + time::{Duration, Instant}, +}; + +use bytes::Bytes; +use futures_util::future::IntoStream; +use http::header::HeaderValue; +use hyper::{ + client::ResponseFuture, + header::{ACCEPT, AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE, RANGE}, + Body, HeaderMap, Method, Request, +}; +use protobuf::Message; +use rand::Rng; +use thiserror::Error; + +use crate::{ + apresolve::SocketAddress, + cdn_url::CdnUrl, + error::ErrorKind, + protocol::{ + canvaz::EntityCanvazRequest, + clienttoken_http::{ClientTokenRequest, ClientTokenRequestType, ClientTokenResponse}, + connect::PutStateRequest, + extended_metadata::BatchedEntityRequest, + }, + token::Token, + version, Error, FileId, SpotifyId, +}; + +component! { + SpClient : SpClientInner { + accesspoint: Option = None, + strategy: RequestStrategy = RequestStrategy::default(), + client_token: Option = None, + } +} + +pub type SpClientResult = Result; + +#[derive(Debug, Error)] +pub enum SpClientError { + #[error("missing attribute {0}")] + Attribute(String), +} + +impl From for Error { + fn from(err: SpClientError) -> Self { + Self::failed_precondition(err) + } +} + +#[derive(Copy, Clone, Debug)] +pub enum RequestStrategy { + TryTimes(usize), + Infinitely, +} + +impl Default for RequestStrategy { + fn default() -> Self { + RequestStrategy::TryTimes(10) + } +} + +impl SpClient { + pub fn set_strategy(&self, strategy: RequestStrategy) { + self.lock(|inner| inner.strategy = strategy) + } + + pub async fn flush_accesspoint(&self) { + self.lock(|inner| inner.accesspoint = None) + } + + pub async fn get_accesspoint(&self) -> Result { + // Memoize the current access point. + let ap = self.lock(|inner| inner.accesspoint.clone()); + let tuple = match ap { + Some(tuple) => tuple, + None => { + let tuple = self.session().apresolver().resolve("spclient").await?; + self.lock(|inner| inner.accesspoint = Some(tuple.clone())); + info!( + "Resolved \"{}:{}\" as spclient access point", + tuple.0, tuple.1 + ); + tuple + } + }; + Ok(tuple) + } + + pub async fn base_url(&self) -> Result { + let ap = self.get_accesspoint().await?; + Ok(format!("https://{}:{}", ap.0, ap.1)) + } + + pub async fn client_token(&self) -> Result { + let client_token = self.lock(|inner| { + if let Some(token) = &inner.client_token { + if token.is_expired() { + inner.client_token = None; + } + } + inner.client_token.clone() + }); + + if let Some(client_token) = client_token { + return Ok(client_token.access_token); + } + + trace!("Client token unavailable or expired, requesting new token."); + + let mut message = ClientTokenRequest::new(); + message.set_request_type(ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST); + + let client_data = message.mut_client_data(); + client_data.set_client_id(self.session().client_id()); + client_data.set_client_version(version::SEMVER.to_string()); + + let connectivity_data = client_data.mut_connectivity_sdk_data(); + connectivity_data.set_device_id(self.session().device_id().to_string()); + + let platform_data = connectivity_data.mut_platform_specific_data(); + + match std::env::consts::OS { + "windows" => { + let (pe, image_file) = match std::env::consts::ARCH { + "arm" => (448, 452), + "aarch64" => (43620, 452), + "x86_64" => (34404, 34404), + _ => (332, 332), // x86 + }; + + let windows_data = platform_data.mut_desktop_windows(); + windows_data.set_os_version(10); + windows_data.set_os_build(21370); + windows_data.set_platform_id(2); + windows_data.set_unknown_value_6(9); + windows_data.set_image_file_machine(image_file); + windows_data.set_pe_machine(pe); + windows_data.set_unknown_value_10(true); + } + "ios" => { + let ios_data = platform_data.mut_ios(); + ios_data.set_user_interface_idiom(0); + ios_data.set_target_iphone_simulator(false); + ios_data.set_hw_machine("iPhone14,5".to_string()); + ios_data.set_system_version("15.2.1".to_string()); + } + "android" => { + let android_data = platform_data.mut_android(); + android_data.set_android_version("12.0.0_r26".to_string()); + android_data.set_api_version(31); + android_data.set_device_name("Pixel".to_owned()); + android_data.set_model_str("GF5KQ".to_owned()); + android_data.set_vendor("Google".to_owned()); + } + "macos" => { + let macos_data = platform_data.mut_desktop_macos(); + macos_data.set_system_version("Darwin Kernel Version 17.7.0: Fri Oct 30 13:34:27 PDT 2020; root:xnu-4570.71.82.8~1/RELEASE_X86_64".to_string()); + macos_data.set_hw_model("iMac21,1".to_string()); + macos_data.set_compiled_cpu_type(std::env::consts::ARCH.to_string()); + } + _ => { + let linux_data = platform_data.mut_desktop_linux(); + linux_data.set_system_name("Linux".to_string()); + linux_data.set_system_release("5.4.0-56-generic".to_string()); + linux_data + .set_system_version("#62-Ubuntu SMP Mon Nov 23 19:20:19 UTC 2020".to_string()); + linux_data.set_hardware(std::env::consts::ARCH.to_string()); + } + } + + let body = message.write_to_bytes()?; + + let request = Request::builder() + .method(&Method::POST) + .uri("https://clienttoken.spotify.com/v1/clienttoken") + .header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) + .header(CONTENT_ENCODING, HeaderValue::from_static("")) + .body(Body::from(body))?; + + let response = self.session().http_client().request_body(request).await?; + let message = ClientTokenResponse::parse_from_bytes(&response)?; + + let client_token = self.lock(|inner| { + let access_token = message.get_granted_token().get_token().to_owned(); + + let client_token = Token { + access_token: access_token.clone(), + expires_in: Duration::from_secs( + message + .get_granted_token() + .get_refresh_after_seconds() + .try_into() + .unwrap_or(7200), + ), + token_type: "client-token".to_string(), + scopes: message + .get_granted_token() + .get_domains() + .iter() + .map(|d| d.domain.clone()) + .collect(), + timestamp: Instant::now(), + }; + + trace!("Got client token: {:?}", client_token); + + inner.client_token = Some(client_token); + access_token + }); + + Ok(client_token) + } + + pub async fn request_with_protobuf( + &self, + method: &Method, + endpoint: &str, + headers: Option, + message: &dyn Message, + ) -> SpClientResult { + let body = protobuf::text_format::print_to_string(message); + + let mut headers = headers.unwrap_or_else(HeaderMap::new); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/x-protobuf"), + ); + + self.request(method, endpoint, Some(headers), Some(body)) + .await + } + + pub async fn request_as_json( + &self, + method: &Method, + endpoint: &str, + headers: Option, + body: Option, + ) -> SpClientResult { + let mut headers = headers.unwrap_or_else(HeaderMap::new); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + + self.request(method, endpoint, Some(headers), body).await + } + + pub async fn request( + &self, + method: &Method, + endpoint: &str, + headers: Option, + body: Option, + ) -> SpClientResult { + let mut tries: usize = 0; + let mut last_response; + + let body = body.unwrap_or_default(); + + loop { + tries += 1; + + // Reconnection logic: retrieve the endpoint every iteration, so we can try + // another access point when we are experiencing network issues (see below). + let mut url = self.base_url().await?; + url.push_str(endpoint); + + // Add metrics. There is also an optional `partner` key with a value like + // `vodafone-uk` but we've yet to discover how we can find that value. + let separator = match url.find('?') { + Some(_) => "&", + None => "?", + }; + let _ = write!(url, "{}product=0", separator); + + let mut request = Request::builder() + .method(method) + .uri(url) + .body(Body::from(body.clone()))?; + + // Reconnection logic: keep getting (cached) tokens because they might have expired. + let token = self + .session() + .token_provider() + .get_token("playlist-read") + .await?; + + let headers_mut = request.headers_mut(); + if let Some(ref hdrs) = headers { + *headers_mut = hdrs.clone(); + } + headers_mut.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("{} {}", token.token_type, token.access_token,))?, + ); + + last_response = self.session().http_client().request_body(request).await; + + if last_response.is_ok() { + return last_response; + } + + // Break before the reconnection logic below, so that the current access point + // is retained when max_tries == 1. Leave it up to the caller when to flush. + if let RequestStrategy::TryTimes(max_tries) = self.lock(|inner| inner.strategy) { + if tries >= max_tries { + break; + } + } + + // Reconnection logic: drop the current access point if we are experiencing issues. + // This will cause the next call to base_url() to resolve a new one. + if let Err(ref network_error) = last_response { + match network_error.kind { + ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => { + // Keep trying the current access point three times before dropping it. + if tries % 3 == 0 { + self.flush_accesspoint().await + } + } + _ => break, // if we can't build the request now, then we won't ever + } + } + + // When retrying, avoid hammering the Spotify infrastructure by sleeping a while. + // The backoff time is chosen randomly from an ever-increasing range. + let max_seconds = u64::pow(tries as u64, 2) * 3; + let backoff = Duration::from_secs(rand::thread_rng().gen_range(1..=max_seconds)); + warn!( + "Unable to complete API request, waiting {} seconds before retrying...", + backoff.as_secs(), + ); + debug!("Error was: {:?}", last_response); + tokio::time::sleep(backoff).await; + } + + last_response + } + + pub async fn put_connect_state( + &self, + connection_id: String, + state: PutStateRequest, + ) -> SpClientResult { + let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id()); + + let mut headers = HeaderMap::new(); + headers.insert("X-Spotify-Connection-Id", connection_id.parse()?); + + self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), &state) + .await + } + + pub async fn get_metadata(&self, scope: &str, id: SpotifyId) -> SpClientResult { + let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?); + self.request(&Method::GET, &endpoint, None, None).await + } + + pub async fn get_track_metadata(&self, track_id: SpotifyId) -> SpClientResult { + self.get_metadata("track", track_id).await + } + + pub async fn get_episode_metadata(&self, episode_id: SpotifyId) -> SpClientResult { + self.get_metadata("episode", episode_id).await + } + + pub async fn get_album_metadata(&self, album_id: SpotifyId) -> SpClientResult { + self.get_metadata("album", album_id).await + } + + pub async fn get_artist_metadata(&self, artist_id: SpotifyId) -> SpClientResult { + self.get_metadata("artist", artist_id).await + } + + pub async fn get_show_metadata(&self, show_id: SpotifyId) -> SpClientResult { + self.get_metadata("show", show_id).await + } + + pub async fn get_lyrics(&self, track_id: SpotifyId) -> SpClientResult { + let endpoint = format!("/color-lyrics/v1/track/{}", track_id.to_base62()?); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_lyrics_for_image( + &self, + track_id: SpotifyId, + image_id: FileId, + ) -> SpClientResult { + let endpoint = format!( + "/color-lyrics/v2/track/{}/image/spotify:image:{}", + track_id.to_base62()?, + image_id + ); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + // TODO: Find endpoint for newer canvas.proto and upgrade to that. + pub async fn get_canvases(&self, request: EntityCanvazRequest) -> SpClientResult { + let endpoint = "/canvaz-cache/v0/canvases"; + self.request_with_protobuf(&Method::POST, endpoint, None, &request) + .await + } + + pub async fn get_extended_metadata(&self, request: BatchedEntityRequest) -> SpClientResult { + let endpoint = "/extended-metadata/v0/extended-metadata"; + self.request_with_protobuf(&Method::POST, endpoint, None, &request) + .await + } + + pub async fn get_audio_storage(&self, file_id: FileId) -> SpClientResult { + let endpoint = format!( + "/storage-resolve/files/audio/interactive/{}", + file_id.to_base16()? + ); + self.request(&Method::GET, &endpoint, None, None).await + } + + pub fn stream_from_cdn( + &self, + cdn_url: &CdnUrl, + offset: usize, + length: usize, + ) -> Result, Error> { + let url = cdn_url.try_get_url()?; + let req = Request::builder() + .method(&Method::GET) + .uri(url) + .header( + RANGE, + HeaderValue::from_str(&format!("bytes={}-{}", offset, offset + length - 1))?, + ) + .body(Body::empty())?; + + let stream = self.session().http_client().request_stream(req)?; + + Ok(stream) + } + + pub async fn request_url(&self, url: String) -> SpClientResult { + let request = Request::builder() + .method(&Method::GET) + .uri(url) + .body(Body::empty())?; + + self.session().http_client().request_body(request).await + } + + // Audio preview in 96 kbps MP3, unencrypted + pub async fn get_audio_preview(&self, preview_id: &FileId) -> SpClientResult { + let attribute = "audio-preview-url-template"; + let template = self + .session() + .get_user_attribute(attribute) + .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?; + + let mut url = template.replace("{id}", &preview_id.to_base16()?); + let separator = match url.find('?') { + Some(_) => "&", + None => "?", + }; + let _ = write!(url, "{}cid={}", separator, self.session().client_id()); + + self.request_url(url).await + } + + // The first 128 kB of a track, unencrypted + pub async fn get_head_file(&self, file_id: FileId) -> SpClientResult { + let attribute = "head-files-url"; + let template = self + .session() + .get_user_attribute(attribute) + .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?; + + let url = template.replace("{file_id}", &file_id.to_base16()?); + + self.request_url(url).await + } + + pub async fn get_image(&self, image_id: FileId) -> SpClientResult { + let attribute = "image-url"; + let template = self + .session() + .get_user_attribute(attribute) + .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?; + let url = template.replace("{file_id}", &image_id.to_base16()?); + + self.request_url(url).await + } +} diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index 10298a42..3db010e9 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -1,44 +1,81 @@ -#![allow(clippy::wrong_self_convention)] +use std::{ + convert::{TryFrom, TryInto}, + fmt, + ops::Deref, +}; -use std::convert::TryInto; -use std::fmt; -use std::string::FromUtf8Error; +use thiserror::Error; + +use crate::Error; + +use librespot_protocol as protocol; + +// re-export FileId for historic reasons, when it was part of this mod +pub use crate::FileId; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SpotifyAudioType { +pub enum SpotifyItemType { + Album, + Artist, + Episode, + Playlist, + Show, Track, - Podcast, - NonPlayable, + Unknown, } -impl From<&str> for SpotifyAudioType { +impl From<&str> for SpotifyItemType { fn from(v: &str) -> Self { match v { - "track" => SpotifyAudioType::Track, - "episode" => SpotifyAudioType::Podcast, - _ => SpotifyAudioType::NonPlayable, + "album" => Self::Album, + "artist" => Self::Artist, + "episode" => Self::Episode, + "playlist" => Self::Playlist, + "show" => Self::Show, + "track" => Self::Track, + _ => Self::Unknown, } } } -impl From for &str { - fn from(audio_type: SpotifyAudioType) -> &'static str { - match audio_type { - SpotifyAudioType::Track => "track", - SpotifyAudioType::Podcast => "episode", - SpotifyAudioType::NonPlayable => "unknown", +impl From for &str { + fn from(item_type: SpotifyItemType) -> &'static str { + match item_type { + SpotifyItemType::Album => "album", + SpotifyItemType::Artist => "artist", + SpotifyItemType::Episode => "episode", + SpotifyItemType::Playlist => "playlist", + SpotifyItemType::Show => "show", + SpotifyItemType::Track => "track", + _ => "unknown", } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct SpotifyId { pub id: u128, - pub audio_type: SpotifyAudioType, + pub item_type: SpotifyItemType, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub struct SpotifyIdError; +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SpotifyIdError { + #[error("ID cannot be parsed")] + InvalidId, + #[error("not a valid Spotify URI")] + InvalidFormat, + #[error("URI does not belong to Spotify")] + InvalidRoot, +} + +impl From for Error { + fn from(err: SpotifyIdError) -> Self { + Error::invalid_argument(err) + } +} + +pub type SpotifyIdResult = Result; +pub type NamedSpotifyIdResult = Result; const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef"; @@ -48,11 +85,12 @@ impl SpotifyId { const SIZE_BASE16: usize = 32; const SIZE_BASE62: usize = 22; - fn track(n: u128) -> SpotifyId { - SpotifyId { - id: n, - audio_type: SpotifyAudioType::Track, - } + /// Returns whether this `SpotifyId` is for a playable audio item, if known. + pub fn is_playable(&self) -> bool { + return matches!( + self.item_type, + SpotifyItemType::Episode | SpotifyItemType::Track + ); } /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`. @@ -60,29 +98,32 @@ impl SpotifyId { /// `src` is expected to be 32 bytes long and encoded using valid characters. /// /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn from_base16(src: &str) -> Result { + pub fn from_base16(src: &str) -> SpotifyIdResult { let mut dst: u128 = 0; for c in src.as_bytes() { let p = match c { b'0'..=b'9' => c - b'0', b'a'..=b'f' => c - b'a' + 10, - _ => return Err(SpotifyIdError), + _ => return Err(SpotifyIdError::InvalidId.into()), } as u128; dst <<= 4; dst += p; } - Ok(SpotifyId::track(dst)) + Ok(Self { + id: dst, + item_type: SpotifyItemType::Unknown, + }) } - /// Parses a base62 encoded [Spotify ID] into a `SpotifyId`. + /// Parses a base62 encoded [Spotify ID] into a `u128`. /// /// `src` is expected to be 22 bytes long and encoded using valid characters. /// /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn from_base62(src: &str) -> Result { + pub fn from_base62(src: &str) -> SpotifyIdResult { let mut dst: u128 = 0; for c in src.as_bytes() { @@ -90,23 +131,29 @@ impl SpotifyId { b'0'..=b'9' => c - b'0', b'a'..=b'z' => c - b'a' + 10, b'A'..=b'Z' => c - b'A' + 36, - _ => return Err(SpotifyIdError), + _ => return Err(SpotifyIdError::InvalidId.into()), } as u128; dst *= 62; dst += p; } - Ok(SpotifyId::track(dst)) + Ok(Self { + id: dst, + item_type: SpotifyItemType::Unknown, + }) } - /// Creates a `SpotifyId` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order. + /// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order. /// - /// The resulting `SpotifyId` will default to a `SpotifyAudioType::TRACK`. - pub fn from_raw(src: &[u8]) -> Result { + /// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`. + pub fn from_raw(src: &[u8]) -> SpotifyIdResult { match src.try_into() { - Ok(dst) => Ok(SpotifyId::track(u128::from_be_bytes(dst))), - Err(_) => Err(SpotifyIdError), + Ok(dst) => Ok(Self { + id: u128::from_be_bytes(dst), + item_type: SpotifyItemType::Unknown, + }), + Err(_) => Err(SpotifyIdError::InvalidId.into()), } } @@ -115,37 +162,46 @@ impl SpotifyId { /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}` /// can be arbitrary while `{id}` is a 22-character long, base62 encoded Spotify ID. /// + /// Note that this should not be used for playlists, which have the form of + /// `spotify:user:{owner_username}:playlist:{id}`. + /// /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn from_uri(src: &str) -> Result { - let src = src.strip_prefix("spotify:").ok_or(SpotifyIdError)?; + pub fn from_uri(src: &str) -> SpotifyIdResult { + let mut uri_parts: Vec<&str> = src.split(':').collect(); - if src.len() <= SpotifyId::SIZE_BASE62 { - return Err(SpotifyIdError); + // At minimum, should be `spotify:{type}:{id}` + if uri_parts.len() < 3 { + return Err(SpotifyIdError::InvalidFormat.into()); } - let colon_index = src.len() - SpotifyId::SIZE_BASE62 - 1; - - if src.as_bytes()[colon_index] != b':' { - return Err(SpotifyIdError); + if uri_parts[0] != "spotify" { + return Err(SpotifyIdError::InvalidRoot.into()); } - let mut id = SpotifyId::from_base62(&src[colon_index + 1..])?; - id.audio_type = src[..colon_index].into(); + let id = uri_parts.pop().unwrap_or_default(); + if id.len() != Self::SIZE_BASE62 { + return Err(SpotifyIdError::InvalidId.into()); + } - Ok(id) + Ok(Self { + item_type: uri_parts.pop().unwrap_or_default().into(), + ..Self::from_base62(id)? + }) } /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32) /// character long `String`. - pub fn to_base16(&self) -> Result { - to_base16(&self.to_raw(), &mut [0u8; SpotifyId::SIZE_BASE16]) + #[allow(clippy::wrong_self_convention)] + pub fn to_base16(&self) -> Result { + to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16]) } /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22) /// character long `String`. /// /// [canonically]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn to_base62(&self) -> Result { + #[allow(clippy::wrong_self_convention)] + pub fn to_base62(&self) -> Result { let mut dst = [0u8; 22]; let mut i = 0; let n = self.id; @@ -183,12 +239,13 @@ impl SpotifyId { dst.reverse(); - String::from_utf8(dst.to_vec()) + String::from_utf8(dst.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into()) } /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in /// big-endian order. - pub fn to_raw(&self) -> [u8; SpotifyId::SIZE] { + #[allow(clippy::wrong_self_convention)] + pub fn to_raw(&self) -> [u8; Self::SIZE] { self.id.to_be_bytes() } @@ -200,44 +257,260 @@ impl SpotifyId { /// be encoded as `unknown`. /// /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn to_uri(&self) -> Result { + #[allow(clippy::wrong_self_convention)] + pub fn to_uri(&self) -> Result { // 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31 - // + unknown size audio_type. - let audio_type: &str = self.audio_type.into(); - let mut dst = String::with_capacity(31 + audio_type.len()); + // + unknown size item_type. + let item_type: &str = self.item_type.into(); + let mut dst = String::with_capacity(31 + item_type.len()); dst.push_str("spotify:"); - dst.push_str(audio_type); + dst.push_str(item_type); dst.push(':'); - let base62 = self.to_base62()?; - dst.push_str(&base62); + let base_62 = self.to_base62()?; + dst.push_str(&base_62); Ok(dst) } } -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct FileId(pub [u8; 20]); - -impl FileId { - pub fn to_base16(&self) -> Result { - to_base16(&self.0, &mut [0u8; 40]) - } -} - -impl fmt::Debug for FileId { +impl fmt::Debug for SpotifyId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_tuple("FileId").field(&self.to_base16()).finish() + f.debug_tuple("SpotifyId") + .field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) + .finish() } } -impl fmt::Display for FileId { +impl fmt::Display for SpotifyId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(&self.to_base16().unwrap_or_default()) + f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) } } -#[inline] -fn to_base16(src: &[u8], buf: &mut [u8]) -> Result { +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct NamedSpotifyId { + pub inner_id: SpotifyId, + pub username: String, +} + +impl NamedSpotifyId { + pub fn from_uri(src: &str) -> NamedSpotifyIdResult { + let uri_parts: Vec<&str> = src.split(':').collect(); + + // At minimum, should be `spotify:user:{username}:{type}:{id}` + if uri_parts.len() < 5 { + return Err(SpotifyIdError::InvalidFormat.into()); + } + + if uri_parts[0] != "spotify" { + return Err(SpotifyIdError::InvalidRoot.into()); + } + + if uri_parts[1] != "user" { + return Err(SpotifyIdError::InvalidFormat.into()); + } + + Ok(Self { + inner_id: SpotifyId::from_uri(src)?, + username: uri_parts[2].to_owned(), + }) + } + + pub fn to_uri(&self) -> Result { + let item_type: &str = self.inner_id.item_type.into(); + let mut dst = String::with_capacity(37 + self.username.len() + item_type.len()); + dst.push_str("spotify:user:"); + dst.push_str(&self.username); + dst.push_str(item_type); + dst.push(':'); + let base_62 = self.to_base62()?; + dst.push_str(&base_62); + + Ok(dst) + } + + pub fn from_spotify_id(id: SpotifyId, username: String) -> Self { + Self { + inner_id: id, + username, + } + } +} + +impl Deref for NamedSpotifyId { + type Target = SpotifyId; + fn deref(&self) -> &Self::Target { + &self.inner_id + } +} + +impl fmt::Debug for NamedSpotifyId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_tuple("NamedSpotifyId") + .field( + &self + .inner_id + .to_uri() + .unwrap_or_else(|_| "invalid id".into()), + ) + .finish() + } +} + +impl fmt::Display for NamedSpotifyId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str( + &self + .inner_id + .to_uri() + .unwrap_or_else(|_| "invalid id".into()), + ) + } +} + +impl TryFrom<&[u8]> for SpotifyId { + type Error = crate::Error; + fn try_from(src: &[u8]) -> Result { + Self::from_raw(src) + } +} + +impl TryFrom<&str> for SpotifyId { + type Error = crate::Error; + fn try_from(src: &str) -> Result { + Self::from_base62(src) + } +} + +impl TryFrom for SpotifyId { + type Error = crate::Error; + fn try_from(src: String) -> Result { + Self::try_from(src.as_str()) + } +} + +impl TryFrom<&Vec> for SpotifyId { + type Error = crate::Error; + fn try_from(src: &Vec) -> Result { + Self::try_from(src.as_slice()) + } +} + +impl TryFrom<&protocol::spirc::TrackRef> for SpotifyId { + type Error = crate::Error; + fn try_from(track: &protocol::spirc::TrackRef) -> Result { + match SpotifyId::from_raw(track.get_gid()) { + Ok(mut id) => { + id.item_type = SpotifyItemType::Track; + Ok(id) + } + Err(_) => SpotifyId::from_uri(track.get_uri()), + } + } +} + +impl TryFrom<&protocol::metadata::Album> for SpotifyId { + type Error = crate::Error; + fn try_from(album: &protocol::metadata::Album) -> Result { + Ok(Self { + item_type: SpotifyItemType::Album, + ..Self::from_raw(album.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::Artist> for SpotifyId { + type Error = crate::Error; + fn try_from(artist: &protocol::metadata::Artist) -> Result { + Ok(Self { + item_type: SpotifyItemType::Artist, + ..Self::from_raw(artist.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::Episode> for SpotifyId { + type Error = crate::Error; + fn try_from(episode: &protocol::metadata::Episode) -> Result { + Ok(Self { + item_type: SpotifyItemType::Episode, + ..Self::from_raw(episode.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::Track> for SpotifyId { + type Error = crate::Error; + fn try_from(track: &protocol::metadata::Track) -> Result { + Ok(Self { + item_type: SpotifyItemType::Track, + ..Self::from_raw(track.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::Show> for SpotifyId { + type Error = crate::Error; + fn try_from(show: &protocol::metadata::Show) -> Result { + Ok(Self { + item_type: SpotifyItemType::Show, + ..Self::from_raw(show.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyId { + type Error = crate::Error; + fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result { + Ok(Self { + item_type: SpotifyItemType::Artist, + ..Self::from_raw(artist.get_artist_gid())? + }) + } +} + +impl TryFrom<&protocol::playlist4_external::Item> for SpotifyId { + type Error = crate::Error; + fn try_from(item: &protocol::playlist4_external::Item) -> Result { + Ok(Self { + item_type: SpotifyItemType::Track, + ..Self::from_uri(item.get_uri())? + }) + } +} + +// Note that this is the unique revision of an item's metadata on a playlist, +// not the ID of that item or playlist. +impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyId { + type Error = crate::Error; + fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result { + Self::try_from(item.get_revision()) + } +} + +// Note that this is the unique revision of a playlist, not the ID of that playlist. +impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyId { + type Error = crate::Error; + fn try_from( + playlist: &protocol::playlist4_external::SelectedListContent, + ) -> Result { + Self::try_from(playlist.get_revision()) + } +} + +// TODO: check meaning and format of this field in the wild. This might be a FileId, +// which is why we now don't create a separate `Playlist` enum value yet and choose +// to discard any item type. +impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyId { + type Error = crate::Error; + fn try_from( + picture: &protocol::playlist_annotate3::TranscodedPicture, + ) -> Result { + Self::from_base62(picture.get_uri()) + } +} + +pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result { let mut i = 0; for v in src { buf[i] = BASE16_DIGITS[(v >> 4) as usize]; @@ -245,7 +518,7 @@ fn to_base16(src: &[u8], buf: &mut [u8]) -> Result { i += 2; } - String::from_utf8(buf.to_vec()) + String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into()) } #[cfg(test)] @@ -254,7 +527,7 @@ mod tests { struct ConversionCase { id: u128, - kind: SpotifyAudioType, + kind: SpotifyItemType, uri: &'static str, base16: &'static str, base62: &'static str, @@ -264,7 +537,7 @@ mod tests { static CONV_VALID: [ConversionCase; 4] = [ ConversionCase { id: 238762092608182713602505436543891614649, - kind: SpotifyAudioType::Track, + kind: SpotifyItemType::Track, uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH", base16: "b39fe8081e1f4c54be38e8d6f9f12bb9", base62: "5sWHDYs0csV6RS48xBl0tH", @@ -274,7 +547,7 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::Track, + kind: SpotifyItemType::Track, uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", @@ -284,7 +557,7 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::Podcast, + kind: SpotifyItemType::Episode, uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", @@ -294,8 +567,8 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::NonPlayable, - uri: "spotify:unknown:4GNcXTGWmnZ3ySrqvol3o4", + kind: SpotifyItemType::Show, + uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ @@ -307,7 +580,7 @@ mod tests { static CONV_INVALID: [ConversionCase; 3] = [ ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, + kind: SpotifyItemType::Unknown, // Invalid ID in the URI. uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH", base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9", @@ -319,7 +592,7 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, + kind: SpotifyItemType::Unknown, // Missing colon between ID and type. uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", base16: "--------------------", @@ -331,7 +604,7 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, + kind: SpotifyItemType::Unknown, // Uri too short uri: "spotify:azb:aRS48xBl0tH", base16: "--------------------", @@ -350,7 +623,7 @@ mod tests { } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_base62(c.base62), Err(SpotifyIdError)); + assert!(SpotifyId::from_base62(c.base62).is_err(),); } } @@ -359,7 +632,7 @@ mod tests { for c in &CONV_VALID { let id = SpotifyId { id: c.id, - audio_type: c.kind, + item_type: c.kind, }; assert_eq!(id.to_base62().unwrap(), c.base62); @@ -373,7 +646,7 @@ mod tests { } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_base16(c.base16), Err(SpotifyIdError)); + assert!(SpotifyId::from_base16(c.base16).is_err(),); } } @@ -382,7 +655,7 @@ mod tests { for c in &CONV_VALID { let id = SpotifyId { id: c.id, - audio_type: c.kind, + item_type: c.kind, }; assert_eq!(id.to_base16().unwrap(), c.base16); @@ -395,11 +668,11 @@ mod tests { let actual = SpotifyId::from_uri(c.uri).unwrap(); assert_eq!(actual.id, c.id); - assert_eq!(actual.audio_type, c.kind); + assert_eq!(actual.item_type, c.kind); } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_uri(c.uri), Err(SpotifyIdError)); + assert!(SpotifyId::from_uri(c.uri).is_err()); } } @@ -408,7 +681,7 @@ mod tests { for c in &CONV_VALID { let id = SpotifyId { id: c.id, - audio_type: c.kind, + item_type: c.kind, }; assert_eq!(id.to_uri().unwrap(), c.uri); @@ -422,7 +695,7 @@ mod tests { } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_raw(c.raw), Err(SpotifyIdError)); + assert!(SpotifyId::from_raw(c.raw).is_err()); } } } diff --git a/core/src/token.rs b/core/src/token.rs new file mode 100644 index 00000000..02f94b60 --- /dev/null +++ b/core/src/token.rs @@ -0,0 +1,138 @@ +// Ported from librespot-java. Relicensed under MIT with permission. + +// Known scopes: +// ugc-image-upload, playlist-read-collaborative, playlist-modify-private, +// playlist-modify-public, playlist-read-private, user-read-playback-position, +// user-read-recently-played, user-top-read, user-modify-playback-state, +// user-read-currently-playing, user-read-playback-state, user-read-private, user-read-email, +// user-library-modify, user-library-read, user-follow-modify, user-follow-read, streaming, +// app-remote-control + +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use thiserror::Error; + +use crate::Error; + +component! { + TokenProvider : TokenProviderInner { + tokens: Vec = vec![], + } +} + +#[derive(Debug, Error)] +pub enum TokenError { + #[error("no tokens available")] + Empty, +} + +impl From for Error { + fn from(err: TokenError) -> Self { + Error::unavailable(err) + } +} + +#[derive(Clone, Debug)] +pub struct Token { + pub access_token: String, + pub expires_in: Duration, + pub token_type: String, + pub scopes: Vec, + pub timestamp: Instant, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenData { + access_token: String, + expires_in: u64, + token_type: String, + scope: Vec, +} + +impl TokenProvider { + fn find_token(&self, scopes: Vec<&str>) -> Option { + self.lock(|inner| { + for i in 0..inner.tokens.len() { + if inner.tokens[i].in_scopes(scopes.clone()) { + return Some(i); + } + } + None + }) + } + + // scopes must be comma-separated + pub async fn get_token(&self, scopes: &str) -> Result { + let client_id = self.session().client_id(); + if client_id.is_empty() { + return Err(Error::invalid_argument("Client ID cannot be empty")); + } + + if let Some(index) = self.find_token(scopes.split(',').collect()) { + let cached_token = self.lock(|inner| inner.tokens[index].clone()); + if cached_token.is_expired() { + self.lock(|inner| inner.tokens.remove(index)); + } else { + return Ok(cached_token); + } + } + + trace!( + "Requested token in scopes {:?} unavailable or expired, requesting new token.", + scopes + ); + + let query_uri = format!( + "hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}", + scopes, + client_id, + self.session().device_id(), + ); + let request = self.session().mercury().get(query_uri)?; + let response = request.await?; + let data = response.payload.first().ok_or(TokenError::Empty)?.to_vec(); + let token = Token::from_json(String::from_utf8(data)?)?; + trace!("Got token: {:#?}", token); + self.lock(|inner| inner.tokens.push(token.clone())); + Ok(token) + } +} + +impl Token { + const EXPIRY_THRESHOLD: Duration = Duration::from_secs(10); + + pub fn from_json(body: String) -> Result { + let data: TokenData = serde_json::from_slice(body.as_ref())?; + Ok(Self { + access_token: data.access_token, + expires_in: Duration::from_secs(data.expires_in), + token_type: data.token_type, + scopes: data.scope, + timestamp: Instant::now(), + }) + } + + pub fn is_expired(&self) -> bool { + self.timestamp + (self.expires_in - Self::EXPIRY_THRESHOLD) < Instant::now() + } + + pub fn in_scope(&self, scope: &str) -> bool { + for s in &self.scopes { + if *s == scope { + return true; + } + } + false + } + + pub fn in_scopes(&self, scopes: Vec<&str>) -> bool { + for s in scopes { + if !self.in_scope(s) { + return false; + } + } + true + } +} diff --git a/core/src/util.rs b/core/src/util.rs index df9ea714..a01f8b56 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -1,4 +1,97 @@ -use std::mem; +use std::{ + future::Future, + mem, + pin::Pin, + task::{Context, Poll}, +}; + +use futures_core::ready; +use futures_util::{future, FutureExt, Sink, SinkExt}; +use tokio::{task::JoinHandle, time::timeout}; + +/// Returns a future that will flush the sink, even if flushing is temporarily completed. +/// Finishes only if the sink throws an error. +pub(crate) fn keep_flushing<'a, T, S: Sink + Unpin + 'a>( + mut s: S, +) -> impl Future + 'a { + future::poll_fn(move |cx| match s.poll_flush_unpin(cx) { + Poll::Ready(Err(e)) => Poll::Ready(e), + _ => Poll::Pending, + }) +} + +pub struct CancelOnDrop(pub JoinHandle); + +impl Future for CancelOnDrop { + type Output = as Future>::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.0.poll_unpin(cx) + } +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +pub struct TimeoutOnDrop { + handle: Option>, + timeout: tokio::time::Duration, +} + +impl TimeoutOnDrop { + pub fn new(handle: JoinHandle, timeout: tokio::time::Duration) -> Self { + Self { + handle: Some(handle), + timeout, + } + } + + pub fn take(&mut self) -> Option> { + self.handle.take() + } +} + +impl Future for TimeoutOnDrop { + type Output = as Future>::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let r = ready!(self + .handle + .as_mut() + .expect("Polled after ready") + .poll_unpin(cx)); + self.handle = None; + Poll::Ready(r) + } +} + +impl Drop for TimeoutOnDrop { + fn drop(&mut self) { + let mut handle = if let Some(handle) = self.handle.take() { + handle + } else { + return; + }; + + if (&mut handle).now_or_never().is_some() { + // Already finished + return; + } + + match tokio::runtime::Handle::try_current() { + Ok(h) => { + h.spawn(timeout(self.timeout, CancelOnDrop(handle))); + } + Err(_) => { + // Not in tokio context, can't spawn + handle.abort(); + } + } + } +} pub trait Seq { fn next(&self) -> Self; diff --git a/core/src/version.rs b/core/src/version.rs index ef553463..aadc1356 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -1,17 +1,26 @@ /// Version string of the form "librespot-" -pub const VERSION_STRING: &str = concat!("librespot-", env!("VERGEN_SHA_SHORT")); +pub const VERSION_STRING: &str = concat!("librespot-", env!("VERGEN_GIT_SHA_SHORT")); /// Generate a timestamp string representing the build date (UTC). pub const BUILD_DATE: &str = env!("VERGEN_BUILD_DATE"); /// Short sha of the latest git commit. -pub const SHA_SHORT: &str = env!("VERGEN_SHA_SHORT"); +pub const SHA_SHORT: &str = env!("VERGEN_GIT_SHA_SHORT"); /// Date of the latest git commit. -pub const COMMIT_DATE: &str = env!("VERGEN_COMMIT_DATE"); +pub const COMMIT_DATE: &str = env!("VERGEN_GIT_COMMIT_DATE"); /// Librespot crate version. pub const SEMVER: &str = env!("CARGO_PKG_VERSION"); /// A random build id. pub const BUILD_ID: &str = env!("LIBRESPOT_BUILD_ID"); + +/// The protocol version of the Spotify desktop client. +pub const SPOTIFY_VERSION: u64 = 117300517; + +/// The protocol version of the Spotify mobile app. +pub const SPOTIFY_MOBILE_VERSION: &str = "8.6.84"; + +/// The user agent to fall back to, if one could not be determined dynamically. +pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)"; diff --git a/core/tests/connect.rs b/core/tests/connect.rs index e6aa7c66..91679f91 100644 --- a/core/tests/connect.rs +++ b/core/tests/connect.rs @@ -1,25 +1,19 @@ use std::time::Duration; -use librespot_core::authentication::Credentials; -use librespot_core::config::SessionConfig; -use librespot_core::session::Session; - use tokio::time::timeout; +use librespot_core::{authentication::Credentials, config::SessionConfig, session::Session}; + #[tokio::test] async fn test_connection() { timeout(Duration::from_secs(30), async { - let result = Session::connect( - SessionConfig::default(), - Credentials::with_password("test", "test"), - None, - false, - ) - .await; + let result = Session::new(SessionConfig::default(), None) + .connect(Credentials::with_password("test", "test"), false) + .await; match result { Ok(_) => panic!("Authentication succeeded despite of bad credentials."), - Err(e) => assert_eq!(e.to_string(), "Login failed with reason: Bad credentials"), + Err(e) => assert!(!e.to_string().is_empty()), // there should be some error message } }) .await diff --git a/discovery/Cargo.toml b/discovery/Cargo.toml index b1d07e48..a83c8e8e 100644 --- a/discovery/Cargo.toml +++ b/discovery/Cargo.toml @@ -8,32 +8,32 @@ repository = "https://github.com/librespot-org/librespot" edition = "2018" [dependencies] -aes-ctr = "0.6" +aes = "0.8" base64 = "0.13" +cfg-if = "1.0" +ctr = "0.9" +dns-sd = { version = "0.1.3", optional = true } form_urlencoded = "1.0" futures-core = "0.3" -hmac = "0.11" -hyper = { version = "0.14", features = ["server", "http1", "tcp"] } +futures-util = "0.3" +hmac = "0.12" +hyper = { version = "0.14", features = ["http1", "server", "tcp"] } libmdns = "0.7" log = "0.4" rand = "0.8" -serde_json = "1.0.25" -sha-1 = "0.9" +serde_json = "1.0" +sha1 = "0.10" thiserror = "1.0" -tokio = { version = "1.0", features = ["sync", "rt"] } - -dns-sd = { version = "0.1.3", optional = true } +tokio = { version = "1", features = ["parking_lot", "sync", "rt"] } [dependencies.librespot-core] path = "../core" -default_features = false version = "0.4.2" [dev-dependencies] futures = "0.3" hex = "0.4" -simple_logger = "2.1" -tokio = { version = "1.0", features = ["macros", "rt"] } +tokio = { version = "1", features = ["macros", "parking_lot", "rt"] } [features] -with-dns-sd = ["dns-sd"] +with-dns-sd = ["dns-sd", "librespot-core/with-dns-sd"] diff --git a/discovery/examples/discovery.rs b/discovery/examples/discovery.rs index cd913fd2..f7dee532 100644 --- a/discovery/examples/discovery.rs +++ b/discovery/examples/discovery.rs @@ -1,15 +1,9 @@ use futures::StreamExt; use librespot_discovery::DeviceType; use sha1::{Digest, Sha1}; -use simple_logger::SimpleLogger; #[tokio::main(flavor = "current_thread")] async fn main() { - SimpleLogger::new() - .with_level(log::LevelFilter::Debug) - .init() - .unwrap(); - let name = "Librespot"; let device_id = hex::encode(Sha1::digest(name.as_bytes())); diff --git a/discovery/src/lib.rs b/discovery/src/lib.rs index ca403b16..718d9dec 100644 --- a/discovery/src/lib.rs +++ b/discovery/src/lib.rs @@ -7,21 +7,23 @@ //! This library uses mDNS and DNS-SD so that other devices can find it, //! and spawns an http server to answer requests of Spotify clients. -#![warn(clippy::all, missing_docs, rust_2018_idioms)] - mod server; -use std::borrow::Cow; -use std::io; -use std::pin::Pin; -use std::task::{Context, Poll}; +use std::{ + borrow::Cow, + io, + pin::Pin, + task::{Context, Poll}, +}; use futures_core::Stream; -use librespot_core as core; use thiserror::Error; use self::server::DiscoveryServer; +pub use crate::core::Error; +use librespot_core as core; + /// Credentials to be used in [`librespot`](`librespot_core`). pub use crate::core::authentication::Credentials; @@ -49,13 +51,29 @@ pub struct Builder { /// Errors that can occur while setting up a [`Discovery`] instance. #[derive(Debug, Error)] -pub enum Error { - /// Setting up service discovery via DNS-SD failed. +pub enum DiscoveryError { + #[error("Creating SHA1 block cipher failed")] + AesError(#[from] aes::cipher::InvalidLength), #[error("Setting up dns-sd failed: {0}")] DnsSdError(#[from] io::Error), - /// Setting up the http server failed. - #[error("Setting up the http server failed: {0}")] + #[error("Creating SHA1 HMAC failed for base key {0:?}")] + HmacError(Vec), + #[error("Setting up the HTTP server failed: {0}")] HttpServerError(#[from] hyper::Error), + #[error("Missing params for key {0}")] + ParamsError(&'static str), +} + +impl From for Error { + fn from(err: DiscoveryError) -> Self { + match err { + DiscoveryError::AesError(_) => Error::unavailable(err), + DiscoveryError::DnsSdError(_) => Error::unavailable(err), + DiscoveryError::HmacError(_) => Error::invalid_argument(err), + DiscoveryError::HttpServerError(_) => Error::unavailable(err), + DiscoveryError::ParamsError(_) => Error::invalid_argument(err), + } + } } impl Builder { @@ -97,7 +115,7 @@ impl Builder { pub fn launch(self) -> Result { let mut port = self.port; let name = self.server_config.name.clone().into_owned(); - let server = DiscoveryServer::new(self.server_config, &mut port)?; + let server = DiscoveryServer::new(self.server_config, &mut port)??; #[cfg(feature = "with-dns-sd")] let svc = dns_sd::DNSService::register( @@ -107,8 +125,7 @@ impl Builder { None, port, &["VERSION=1.0", "CPath=/"], - ) - .map_err(|e| Error::DnsSdError(io::Error::new(io::ErrorKind::Unsupported, e)))?; + )?; #[cfg(not(feature = "with-dns-sd"))] let svc = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?.register( diff --git a/discovery/src/server.rs b/discovery/src/server.rs index 57f5bf46..3edc2fb6 100644 --- a/discovery/src/server.rs +++ b/discovery/src/server.rs @@ -1,26 +1,34 @@ -use std::borrow::Cow; -use std::collections::BTreeMap; -use std::convert::Infallible; -use std::net::{Ipv4Addr, SocketAddr}; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; +use std::{ + borrow::Cow, + collections::BTreeMap, + convert::Infallible, + net::{Ipv4Addr, SocketAddr}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; -use aes_ctr::cipher::generic_array::GenericArray; -use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher}; -use aes_ctr::Aes128Ctr; +use aes::cipher::{KeyIvInit, StreamCipher}; use futures_core::Stream; -use hmac::{Hmac, Mac, NewMac}; -use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Method, Request, Response, StatusCode}; -use log::{debug, warn}; +use futures_util::{FutureExt, TryFutureExt}; +use hmac::{Hmac, Mac}; +use hyper::{ + service::{make_service_fn, service_fn}, + Body, Method, Request, Response, StatusCode, +}; +use log::{debug, error, warn}; use serde_json::json; use sha1::{Digest, Sha1}; use tokio::sync::{mpsc, oneshot}; -use crate::core::authentication::Credentials; -use crate::core::config::DeviceType; -use crate::core::diffie_hellman::DhLocalKeys; +use super::DiscoveryError; + +use crate::{ + core::config::DeviceType, + core::{authentication::Credentials, diffie_hellman::DhLocalKeys, Error}, +}; + +type Aes128Ctr = ctr::Ctr128BE; type Params<'a> = BTreeMap, Cow<'a, str>>; @@ -57,7 +65,7 @@ impl RequestHandler { "status": 101, "statusString": "ERROR-OK", "spotifyError": 0, - "version": "2.7.1", + "version": crate::core::version::SEMVER, "deviceID": (self.config.device_id), "remoteName": (self.config.name), "activeUser": "", @@ -76,41 +84,58 @@ impl RequestHandler { Response::new(Body::from(body)) } - fn handle_add_user(&self, params: &Params<'_>) -> Response { - let username = params.get("userName").unwrap().as_ref(); - let encrypted_blob = params.get("blob").unwrap(); - let client_key = params.get("clientKey").unwrap(); + fn handle_add_user(&self, params: &Params<'_>) -> Result, Error> { + let username_key = "userName"; + let username = params + .get(username_key) + .ok_or(DiscoveryError::ParamsError(username_key))? + .as_ref(); - let encrypted_blob = base64::decode(encrypted_blob.as_bytes()).unwrap(); + let blob_key = "blob"; + let encrypted_blob = params + .get(blob_key) + .ok_or(DiscoveryError::ParamsError(blob_key))?; - let client_key = base64::decode(client_key.as_bytes()).unwrap(); + let clientkey_key = "clientKey"; + let client_key = params + .get(clientkey_key) + .ok_or(DiscoveryError::ParamsError(clientkey_key))?; + + let encrypted_blob = base64::decode(encrypted_blob.as_bytes())?; + + let client_key = base64::decode(client_key.as_bytes())?; let shared_key = self.keys.shared_secret(&client_key); + let encrypted_blob_len = encrypted_blob.len(); + if encrypted_blob_len < 16 { + return Err(DiscoveryError::HmacError(encrypted_blob.to_vec()).into()); + } + let iv = &encrypted_blob[0..16]; - let encrypted = &encrypted_blob[16..encrypted_blob.len() - 20]; - let cksum = &encrypted_blob[encrypted_blob.len() - 20..encrypted_blob.len()]; + let encrypted = &encrypted_blob[16..encrypted_blob_len - 20]; + let cksum = &encrypted_blob[encrypted_blob_len - 20..encrypted_blob_len]; let base_key = Sha1::digest(&shared_key); let base_key = &base_key[..16]; let checksum_key = { - let mut h = - Hmac::::new_from_slice(base_key).expect("HMAC can take key of any size"); + let mut h = Hmac::::new_from_slice(base_key) + .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?; h.update(b"checksum"); h.finalize().into_bytes() }; let encryption_key = { - let mut h = - Hmac::::new_from_slice(base_key).expect("HMAC can take key of any size"); + let mut h = Hmac::::new_from_slice(base_key) + .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?; h.update(b"encryption"); h.finalize().into_bytes() }; - let mut h = - Hmac::::new_from_slice(&checksum_key).expect("HMAC can take key of any size"); + let mut h = Hmac::::new_from_slice(&checksum_key) + .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?; h.update(encrypted); - if h.verify(cksum).is_err() { + if h.verify_slice(cksum).is_err() { warn!("Login error for user {:?}: MAC mismatch", username); let result = json!({ "status": 102, @@ -119,22 +144,20 @@ impl RequestHandler { }); let body = result.to_string(); - return Response::new(Body::from(body)); + return Ok(Response::new(Body::from(body))); } let decrypted = { let mut data = encrypted.to_vec(); - let mut cipher = Aes128Ctr::new( - GenericArray::from_slice(&encryption_key[0..16]), - GenericArray::from_slice(iv), - ); + let mut cipher = Aes128Ctr::new_from_slices(&encryption_key[0..16], iv) + .map_err(DiscoveryError::AesError)?; cipher.apply_keystream(&mut data); data }; - let credentials = Credentials::with_blob(username, &decrypted, &self.config.device_id); + let credentials = Credentials::with_blob(username, &decrypted, &self.config.device_id)?; - self.tx.send(credentials).unwrap(); + self.tx.send(credentials)?; let result = json!({ "status": 101, @@ -143,7 +166,7 @@ impl RequestHandler { }); let body = result.to_string(); - Response::new(Body::from(body)) + Ok(Response::new(Body::from(body))) } fn not_found(&self) -> Response { @@ -152,7 +175,10 @@ impl RequestHandler { res } - async fn handle(self: Arc, request: Request) -> hyper::Result> { + async fn handle( + self: Arc, + request: Request, + ) -> Result>, Error> { let mut params = Params::new(); let (parts, body) = request.into_parts(); @@ -172,11 +198,11 @@ impl RequestHandler { let action = params.get("action").map(Cow::as_ref); - Ok(match (parts.method, action) { + Ok(Ok(match (parts.method, action) { (Method::GET, Some("getInfo")) => self.handle_get_info(), - (Method::POST, Some("addUser")) => self.handle_add_user(¶ms), + (Method::POST, Some("addUser")) => self.handle_add_user(¶ms)?, _ => self.not_found(), - }) + })) } } @@ -186,7 +212,7 @@ pub struct DiscoveryServer { } impl DiscoveryServer { - pub fn new(config: Config, port: &mut u16) -> hyper::Result { + pub fn new(config: Config, port: &mut u16) -> Result, Error> { let (discovery, cred_rx) = RequestHandler::new(config); let discovery = Arc::new(discovery); @@ -197,7 +223,14 @@ impl DiscoveryServer { let make_service = make_service_fn(move |_| { let discovery = discovery.clone(); async move { - Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().handle(request))) + Ok::<_, hyper::Error>(service_fn(move |request| { + discovery + .clone() + .handle(request) + .inspect_err(|e| error!("could not handle discovery request: {}", e)) + .and_then(|x| async move { Ok(x) }) + .map(Result::unwrap) // guaranteed by `and_then` above + })) } }); @@ -209,8 +242,10 @@ impl DiscoveryServer { tokio::spawn(async { let result = server .with_graceful_shutdown(async { - close_rx.await.unwrap_err(); debug!("Shutting down discovery server"); + if close_rx.await.is_ok() { + debug!("unable to close discovery Rx channel completely"); + } }) .await; @@ -219,10 +254,10 @@ impl DiscoveryServer { } }); - Ok(Self { + Ok(Ok(Self { cred_rx, _close_tx: close_tx, - }) + })) } } diff --git a/examples/get_token.rs b/examples/get_token.rs index 4d9e1f1c..0473e122 100644 --- a/examples/get_token.rs +++ b/examples/get_token.rs @@ -1,9 +1,6 @@ use std::env; -use librespot::core::authentication::Credentials; -use librespot::core::config::SessionConfig; -use librespot::core::keymaster; -use librespot::core::session::Session; +use librespot::core::{authentication::Credentials, config::SessionConfig, session::Session}; const SCOPES: &str = "streaming,user-read-playback-state,user-modify-playback-state,user-read-currently-playing"; @@ -13,21 +10,20 @@ async fn main() { let session_config = SessionConfig::default(); let args: Vec<_> = env::args().collect(); - if args.len() != 4 { - eprintln!("Usage: {} USERNAME PASSWORD CLIENT_ID", args[0]); + if args.len() != 3 { + eprintln!("Usage: {} USERNAME PASSWORD", args[0]); return; } - println!("Connecting.."); + println!("Connecting..."); let credentials = Credentials::with_password(&args[1], &args[2]); - let (session, _) = Session::connect(session_config, credentials, None, false) - .await - .unwrap(); + let session = Session::new(session_config, None); - println!( - "Token: {:#?}", - keymaster::get_token(&session, &args[3], SCOPES) - .await - .unwrap() - ); + match session.connect(credentials, false).await { + Ok(()) => println!( + "Token: {:#?}", + session.token_provider().get_token(SCOPES).await.unwrap() + ), + Err(e) => println!("Error connecting: {}", e), + } } diff --git a/examples/play.rs b/examples/play.rs index a91b6851..dc22103b 100644 --- a/examples/play.rs +++ b/examples/play.rs @@ -1,13 +1,16 @@ -use std::env; +use std::{env, process::exit}; -use librespot::core::authentication::Credentials; -use librespot::core::config::SessionConfig; -use librespot::core::session::Session; -use librespot::core::spotify_id::SpotifyId; -use librespot::playback::audio_backend; -use librespot::playback::config::{AudioFormat, PlayerConfig}; -use librespot::playback::mixer::NoOpVolume; -use librespot::playback::player::Player; +use librespot::{ + core::{ + authentication::Credentials, config::SessionConfig, session::Session, spotify_id::SpotifyId, + }, + playback::{ + audio_backend, + config::{AudioFormat, PlayerConfig}, + mixer::NoOpVolume, + player::Player, + }, +}; #[tokio::main] async fn main() { @@ -26,10 +29,12 @@ async fn main() { let backend = audio_backend::find(None).unwrap(); - println!("Connecting .."); - let (session, _) = Session::connect(session_config, credentials, None, false) - .await - .unwrap(); + println!("Connecting..."); + let session = Session::new(session_config, None); + if let Err(e) = session.connect(credentials, false).await { + println!("Error connecting: {}", e); + exit(1); + } let (mut player, _) = Player::new(player_config, session, Box::new(NoOpVolume), move || { backend(None, audio_format) diff --git a/examples/playlist_tracks.rs b/examples/playlist_tracks.rs index 8dbe1d5f..7404e755 100644 --- a/examples/playlist_tracks.rs +++ b/examples/playlist_tracks.rs @@ -1,11 +1,11 @@ -use std::env; -use std::process; +use std::{env, process::exit}; -use librespot::core::authentication::Credentials; -use librespot::core::config::SessionConfig; -use librespot::core::session::Session; -use librespot::core::spotify_id::SpotifyId; -use librespot::metadata::{Metadata, Playlist, Track}; +use librespot::{ + core::{ + authentication::Credentials, config::SessionConfig, session::Session, spotify_id::SpotifyId, + }, + metadata::{Metadata, Playlist, Track}, +}; #[tokio::main] async fn main() { @@ -24,16 +24,18 @@ async fn main() { "PLAYLIST should be a playlist URI such as: \ \"spotify:playlist:37i9dQZF1DXec50AjHrNTq\"" ); - process::exit(1); + exit(1); }); - let (session, _) = Session::connect(session_config, credentials, None, false) - .await - .unwrap(); + let session = Session::new(session_config, None); + if let Err(e) = session.connect(credentials, false).await { + println!("Error connecting: {}", e); + exit(1); + } let plist = Playlist::get(&session, plist_uri).await.unwrap(); println!("{:?}", plist); - for track_id in plist.tracks { + for track_id in plist.tracks() { let plist_track = Track::get(&session, track_id).await.unwrap(); println!("track: {} ", plist_track.name); } diff --git a/metadata/Cargo.toml b/metadata/Cargo.toml index 69a4d6a1..7b0a72ff 100644 --- a/metadata/Cargo.toml +++ b/metadata/Cargo.toml @@ -9,13 +9,17 @@ edition = "2018" [dependencies] async-trait = "0.1" -byteorder = "1.3" -protobuf = "2.14.0" +byteorder = "1" +bytes = "1" log = "0.4" +protobuf = "2" +thiserror = "1" +uuid = { version = "1", default-features = false } [dependencies.librespot-core] path = "../core" version = "0.4.2" + [dependencies.librespot-protocol] path = "../protocol" version = "0.4.2" diff --git a/metadata/src/album.rs b/metadata/src/album.rs new file mode 100644 index 00000000..8a372245 --- /dev/null +++ b/metadata/src/album.rs @@ -0,0 +1,137 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use crate::{ + artist::Artists, availability::Availabilities, copyright::Copyrights, external_id::ExternalIds, + image::Images, request::RequestResult, restriction::Restrictions, sale_period::SalePeriods, + track::Tracks, util::try_from_repeated_message, Metadata, +}; + +use librespot_core::{date::Date, Error, Session, SpotifyId}; + +use librespot_protocol as protocol; +pub use protocol::metadata::Album_Type as AlbumType; +use protocol::metadata::Disc as DiscMessage; + +#[derive(Debug, Clone)] +pub struct Album { + pub id: SpotifyId, + pub name: String, + pub artists: Artists, + pub album_type: AlbumType, + pub label: String, + pub date: Date, + pub popularity: i32, + pub genres: Vec, + pub covers: Images, + pub external_ids: ExternalIds, + pub discs: Discs, + pub reviews: Vec, + pub copyrights: Copyrights, + pub restrictions: Restrictions, + pub related: Albums, + pub sale_periods: SalePeriods, + pub cover_group: Images, + pub original_title: String, + pub version_title: String, + pub type_str: String, + pub availability: Availabilities, +} + +#[derive(Debug, Clone)] +pub struct Albums(pub Vec); + +impl Deref for Albums { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct Disc { + pub number: i32, + pub name: String, + pub tracks: Tracks, +} + +#[derive(Debug, Clone)] +pub struct Discs(pub Vec); + +impl Deref for Discs { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Album { + pub fn tracks(&self) -> Tracks { + let result = self + .discs + .iter() + .flat_map(|disc| disc.tracks.deref().clone()) + .collect(); + Tracks(result) + } +} + +#[async_trait] +impl Metadata for Album { + type Message = protocol::metadata::Album; + + async fn request(session: &Session, album_id: SpotifyId) -> RequestResult { + session.spclient().get_album_metadata(album_id).await + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Album { + type Error = librespot_core::Error; + fn try_from(album: &::Message) -> Result { + Ok(Self { + id: album.try_into()?, + name: album.get_name().to_owned(), + artists: album.get_artist().try_into()?, + album_type: album.get_field_type(), + label: album.get_label().to_owned(), + date: album.get_date().try_into()?, + popularity: album.get_popularity(), + genres: album.get_genre().to_vec(), + covers: album.get_cover().into(), + external_ids: album.get_external_id().into(), + discs: album.get_disc().try_into()?, + reviews: album.get_review().to_vec(), + copyrights: album.get_copyright().into(), + restrictions: album.get_restriction().into(), + related: album.get_related().try_into()?, + sale_periods: album.get_sale_period().try_into()?, + cover_group: album.get_cover_group().get_image().into(), + original_title: album.get_original_title().to_owned(), + version_title: album.get_version_title().to_owned(), + type_str: album.get_type_str().to_owned(), + availability: album.get_availability().try_into()?, + }) + } +} + +try_from_repeated_message!(::Message, Albums); + +impl TryFrom<&DiscMessage> for Disc { + type Error = librespot_core::Error; + fn try_from(disc: &DiscMessage) -> Result { + Ok(Self { + number: disc.get_number(), + name: disc.get_name().to_owned(), + tracks: disc.get_track().try_into()?, + }) + } +} + +try_from_repeated_message!(DiscMessage, Discs); diff --git a/metadata/src/artist.rs b/metadata/src/artist.rs new file mode 100644 index 00000000..ac07d90e --- /dev/null +++ b/metadata/src/artist.rs @@ -0,0 +1,129 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use crate::{request::RequestResult, track::Tracks, util::try_from_repeated_message, Metadata}; + +use librespot_core::{Error, Session, SpotifyId}; + +use librespot_protocol as protocol; +use protocol::metadata::ArtistWithRole as ArtistWithRoleMessage; +pub use protocol::metadata::ArtistWithRole_ArtistRole as ArtistRole; +use protocol::metadata::TopTracks as TopTracksMessage; + +#[derive(Debug, Clone)] +pub struct Artist { + pub id: SpotifyId, + pub name: String, + pub top_tracks: CountryTopTracks, +} + +#[derive(Debug, Clone)] +pub struct Artists(pub Vec); + +impl Deref for Artists { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct ArtistWithRole { + pub id: SpotifyId, + pub name: String, + pub role: ArtistRole, +} + +#[derive(Debug, Clone)] +pub struct ArtistsWithRole(pub Vec); + +impl Deref for ArtistsWithRole { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct TopTracks { + pub country: String, + pub tracks: Tracks, +} + +#[derive(Debug, Clone)] +pub struct CountryTopTracks(pub Vec); + +impl Deref for CountryTopTracks { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CountryTopTracks { + pub fn for_country(&self, country: &str) -> Tracks { + if let Some(country) = self.0.iter().find(|top_track| top_track.country == country) { + return country.tracks.clone(); + } + + if let Some(global) = self.0.iter().find(|top_track| top_track.country.is_empty()) { + return global.tracks.clone(); + } + + Tracks(vec![]) // none found + } +} + +#[async_trait] +impl Metadata for Artist { + type Message = protocol::metadata::Artist; + + async fn request(session: &Session, artist_id: SpotifyId) -> RequestResult { + session.spclient().get_artist_metadata(artist_id).await + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Artist { + type Error = librespot_core::Error; + fn try_from(artist: &::Message) -> Result { + Ok(Self { + id: artist.try_into()?, + name: artist.get_name().to_owned(), + top_tracks: artist.get_top_track().try_into()?, + }) + } +} + +try_from_repeated_message!(::Message, Artists); + +impl TryFrom<&ArtistWithRoleMessage> for ArtistWithRole { + type Error = librespot_core::Error; + fn try_from(artist_with_role: &ArtistWithRoleMessage) -> Result { + Ok(Self { + id: artist_with_role.try_into()?, + name: artist_with_role.get_artist_name().to_owned(), + role: artist_with_role.get_role(), + }) + } +} + +try_from_repeated_message!(ArtistWithRoleMessage, ArtistsWithRole); + +impl TryFrom<&TopTracksMessage> for TopTracks { + type Error = librespot_core::Error; + fn try_from(top_tracks: &TopTracksMessage) -> Result { + Ok(Self { + country: top_tracks.get_country().to_owned(), + tracks: top_tracks.get_track().try_into()?, + }) + } +} + +try_from_repeated_message!(TopTracksMessage, CountryTopTracks); diff --git a/metadata/src/audio/file.rs b/metadata/src/audio/file.rs new file mode 100644 index 00000000..237b8e31 --- /dev/null +++ b/metadata/src/audio/file.rs @@ -0,0 +1,62 @@ +use std::{collections::HashMap, fmt::Debug, ops::Deref}; + +use librespot_core::FileId; + +use librespot_protocol as protocol; +use protocol::metadata::AudioFile as AudioFileMessage; +pub use protocol::metadata::AudioFile_Format as AudioFileFormat; + +#[derive(Debug, Clone)] +pub struct AudioFiles(pub HashMap); + +impl Deref for AudioFiles { + type Target = HashMap; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AudioFiles { + pub fn is_ogg_vorbis(format: AudioFileFormat) -> bool { + matches!( + format, + AudioFileFormat::OGG_VORBIS_320 + | AudioFileFormat::OGG_VORBIS_160 + | AudioFileFormat::OGG_VORBIS_96 + ) + } + + pub fn is_mp3(format: AudioFileFormat) -> bool { + matches!( + format, + AudioFileFormat::MP3_320 + | AudioFileFormat::MP3_256 + | AudioFileFormat::MP3_160 + | AudioFileFormat::MP3_96 + | AudioFileFormat::MP3_160_ENC + ) + } + + pub fn is_flac(format: AudioFileFormat) -> bool { + matches!(format, AudioFileFormat::FLAC_FLAC) + } +} + +impl From<&[AudioFileMessage]> for AudioFiles { + fn from(files: &[AudioFileMessage]) -> Self { + let audio_files = files + .iter() + .filter_map(|file| { + let file_id = FileId::from(file.get_file_id()); + if file.has_format() { + Some((file.get_format(), file_id)) + } else { + trace!("Ignoring file <{}> with unspecified format", file_id); + None + } + }) + .collect(); + + AudioFiles(audio_files) + } +} diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs new file mode 100644 index 00000000..c3df12e0 --- /dev/null +++ b/metadata/src/audio/item.rs @@ -0,0 +1,113 @@ +use std::fmt::Debug; + +use crate::{ + availability::{AudioItemAvailability, Availabilities, UnavailabilityReason}, + episode::Episode, + error::MetadataError, + restriction::Restrictions, + track::{Track, Tracks}, +}; + +use super::file::AudioFiles; + +use librespot_core::{ + date::Date, session::UserData, spotify_id::SpotifyItemType, Error, Session, SpotifyId, +}; + +pub type AudioItemResult = Result; + +// A wrapper with fields the player needs +#[derive(Debug, Clone)] +pub struct AudioItem { + pub id: SpotifyId, + pub spotify_uri: String, + pub files: AudioFiles, + pub name: String, + pub duration: i32, + pub availability: AudioItemAvailability, + pub alternatives: Option, + pub is_explicit: bool, +} + +impl AudioItem { + pub async fn get_file(session: &Session, id: SpotifyId) -> AudioItemResult { + match id.item_type { + SpotifyItemType::Track => Track::get_audio_item(session, id).await, + SpotifyItemType::Episode => Episode::get_audio_item(session, id).await, + _ => Err(Error::unavailable(MetadataError::NonPlayable)), + } + } +} + +#[async_trait] +pub trait InnerAudioItem { + async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult; + + fn allowed_for_user( + user_data: &UserData, + restrictions: &Restrictions, + ) -> AudioItemAvailability { + let country = &user_data.country; + let user_catalogue = match user_data.attributes.get("catalogue") { + Some(catalogue) => catalogue, + None => "premium", + }; + + for premium_restriction in restrictions.iter().filter(|restriction| { + restriction + .catalogue_strs + .iter() + .any(|restricted_catalogue| restricted_catalogue == user_catalogue) + }) { + if let Some(allowed_countries) = &premium_restriction.countries_allowed { + // A restriction will specify either a whitelast *or* a blacklist, + // but not both. So restrict availability if there is a whitelist + // and the country isn't on it. + if allowed_countries.iter().any(|allowed| country == allowed) { + return Ok(()); + } else { + return Err(UnavailabilityReason::NotWhitelisted); + } + } + + if let Some(forbidden_countries) = &premium_restriction.countries_forbidden { + if forbidden_countries + .iter() + .any(|forbidden| country == forbidden) + { + return Err(UnavailabilityReason::Blacklisted); + } else { + return Ok(()); + } + } + } + + Ok(()) // no restrictions in place + } + + fn available(availability: &Availabilities) -> AudioItemAvailability { + if availability.is_empty() { + // not all items have availability specified + return Ok(()); + } + + if !(availability + .iter() + .any(|availability| Date::now_utc() >= availability.start)) + { + return Err(UnavailabilityReason::Embargo); + } + + Ok(()) + } + + fn available_for_user( + user_data: &UserData, + availability: &Availabilities, + restrictions: &Restrictions, + ) -> AudioItemAvailability { + Self::available(availability)?; + Self::allowed_for_user(user_data, restrictions)?; + Ok(()) + } +} diff --git a/metadata/src/audio/mod.rs b/metadata/src/audio/mod.rs new file mode 100644 index 00000000..7e31f190 --- /dev/null +++ b/metadata/src/audio/mod.rs @@ -0,0 +1,5 @@ +pub mod file; +pub mod item; + +pub use file::{AudioFileFormat, AudioFiles}; +pub use item::AudioItem; diff --git a/metadata/src/availability.rs b/metadata/src/availability.rs new file mode 100644 index 00000000..09016a5b --- /dev/null +++ b/metadata/src/availability.rs @@ -0,0 +1,56 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use thiserror::Error; + +use crate::util::try_from_repeated_message; + +use librespot_core::date::Date; + +use librespot_protocol as protocol; +use protocol::metadata::Availability as AvailabilityMessage; + +pub type AudioItemAvailability = Result<(), UnavailabilityReason>; + +#[derive(Debug, Clone)] +pub struct Availability { + pub catalogue_strs: Vec, + pub start: Date, +} + +#[derive(Debug, Clone)] +pub struct Availabilities(pub Vec); + +impl Deref for Availabilities { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Copy, Clone, Error)] +pub enum UnavailabilityReason { + #[error("blacklist present and country on it")] + Blacklisted, + #[error("available date is in the future")] + Embargo, + #[error("required data was not present")] + NoData, + #[error("whitelist present and country not on it")] + NotWhitelisted, +} + +impl TryFrom<&AvailabilityMessage> for Availability { + type Error = librespot_core::Error; + fn try_from(availability: &AvailabilityMessage) -> Result { + Ok(Self { + catalogue_strs: availability.get_catalogue_str().to_vec(), + start: availability.get_start().try_into()?, + }) + } +} + +try_from_repeated_message!(AvailabilityMessage, Availabilities); diff --git a/metadata/src/content_rating.rs b/metadata/src/content_rating.rs new file mode 100644 index 00000000..343f0e26 --- /dev/null +++ b/metadata/src/content_rating.rs @@ -0,0 +1,33 @@ +use std::{fmt::Debug, ops::Deref}; + +use crate::util::from_repeated_message; + +use librespot_protocol as protocol; +use protocol::metadata::ContentRating as ContentRatingMessage; + +#[derive(Debug, Clone)] +pub struct ContentRating { + pub country: String, + pub tags: Vec, +} + +#[derive(Debug, Clone)] +pub struct ContentRatings(pub Vec); + +impl Deref for ContentRatings { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&ContentRatingMessage> for ContentRating { + fn from(content_rating: &ContentRatingMessage) -> Self { + Self { + country: content_rating.get_country().to_owned(), + tags: content_rating.get_tag().to_vec(), + } + } +} + +from_repeated_message!(ContentRatingMessage, ContentRatings); diff --git a/metadata/src/copyright.rs b/metadata/src/copyright.rs new file mode 100644 index 00000000..b7f0e838 --- /dev/null +++ b/metadata/src/copyright.rs @@ -0,0 +1,34 @@ +use std::{fmt::Debug, ops::Deref}; + +use crate::util::from_repeated_message; + +use librespot_protocol as protocol; +use protocol::metadata::Copyright as CopyrightMessage; +pub use protocol::metadata::Copyright_Type as CopyrightType; + +#[derive(Debug, Clone)] +pub struct Copyright { + pub copyright_type: CopyrightType, + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct Copyrights(pub Vec); + +impl Deref for Copyrights { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&CopyrightMessage> for Copyright { + fn from(copyright: &CopyrightMessage) -> Self { + Self { + copyright_type: copyright.get_field_type(), + text: copyright.get_text().to_owned(), + } + } +} + +from_repeated_message!(CopyrightMessage, Copyrights); diff --git a/metadata/src/cover.rs b/metadata/src/cover.rs deleted file mode 100644 index 408e658e..00000000 --- a/metadata/src/cover.rs +++ /dev/null @@ -1,19 +0,0 @@ -use byteorder::{BigEndian, WriteBytesExt}; -use std::io::Write; - -use librespot_core::channel::ChannelData; -use librespot_core::session::Session; -use librespot_core::spotify_id::FileId; - -pub fn get(session: &Session, file: FileId) -> ChannelData { - let (channel_id, channel) = session.channel().allocate(); - let (_headers, data) = channel.split(); - - let mut packet: Vec = Vec::new(); - packet.write_u16::(channel_id).unwrap(); - packet.write_u16::(0).unwrap(); - packet.write(&file.0).unwrap(); - session.send_packet(0x19, packet); - - data -} diff --git a/metadata/src/episode.rs b/metadata/src/episode.rs new file mode 100644 index 00000000..c5b65f80 --- /dev/null +++ b/metadata/src/episode.rs @@ -0,0 +1,133 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use crate::{ + audio::{ + file::AudioFiles, + item::{AudioItem, AudioItemResult, InnerAudioItem}, + }, + availability::Availabilities, + content_rating::ContentRatings, + image::Images, + request::RequestResult, + restriction::Restrictions, + util::try_from_repeated_message, + video::VideoFiles, + Metadata, +}; + +use librespot_core::{date::Date, Error, Session, SpotifyId}; + +use librespot_protocol as protocol; +pub use protocol::metadata::Episode_EpisodeType as EpisodeType; + +#[derive(Debug, Clone)] +pub struct Episode { + pub id: SpotifyId, + pub name: String, + pub duration: i32, + pub audio: AudioFiles, + pub description: String, + pub number: i32, + pub publish_time: Date, + pub covers: Images, + pub language: String, + pub is_explicit: bool, + pub show: SpotifyId, + pub videos: VideoFiles, + pub video_previews: VideoFiles, + pub audio_previews: AudioFiles, + pub restrictions: Restrictions, + pub freeze_frames: Images, + pub keywords: Vec, + pub allow_background_playback: bool, + pub availability: Availabilities, + pub external_url: String, + pub episode_type: EpisodeType, + pub has_music_and_talk: bool, + pub content_rating: ContentRatings, + pub is_audiobook_chapter: bool, +} + +#[derive(Debug, Clone)] +pub struct Episodes(pub Vec); + +impl Deref for Episodes { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait] +impl InnerAudioItem for Episode { + async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult { + let episode = Self::get(session, id).await?; + let availability = Self::available_for_user( + &session.user_data(), + &episode.availability, + &episode.restrictions, + ); + + Ok(AudioItem { + id, + spotify_uri: id.to_uri()?, + files: episode.audio, + name: episode.name, + duration: episode.duration, + availability, + alternatives: None, + is_explicit: episode.is_explicit, + }) + } +} + +#[async_trait] +impl Metadata for Episode { + type Message = protocol::metadata::Episode; + + async fn request(session: &Session, episode_id: SpotifyId) -> RequestResult { + session.spclient().get_episode_metadata(episode_id).await + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Episode { + type Error = librespot_core::Error; + fn try_from(episode: &::Message) -> Result { + Ok(Self { + id: episode.try_into()?, + name: episode.get_name().to_owned(), + duration: episode.get_duration().to_owned(), + audio: episode.get_audio().into(), + description: episode.get_description().to_owned(), + number: episode.get_number(), + publish_time: episode.get_publish_time().try_into()?, + covers: episode.get_cover_image().get_image().into(), + language: episode.get_language().to_owned(), + is_explicit: episode.get_explicit().to_owned(), + show: episode.get_show().try_into()?, + videos: episode.get_video().into(), + video_previews: episode.get_video_preview().into(), + audio_previews: episode.get_audio_preview().into(), + restrictions: episode.get_restriction().into(), + freeze_frames: episode.get_freeze_frame().get_image().into(), + keywords: episode.get_keyword().to_vec(), + allow_background_playback: episode.get_allow_background_playback(), + availability: episode.get_availability().try_into()?, + external_url: episode.get_external_url().to_owned(), + episode_type: episode.get_field_type(), + has_music_and_talk: episode.get_music_and_talk(), + content_rating: episode.get_content_rating().into(), + is_audiobook_chapter: episode.get_is_audiobook_chapter(), + }) + } +} + +try_from_repeated_message!(::Message, Episodes); diff --git a/metadata/src/error.rs b/metadata/src/error.rs new file mode 100644 index 00000000..31c600b0 --- /dev/null +++ b/metadata/src/error.rs @@ -0,0 +1,10 @@ +use std::fmt::Debug; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum MetadataError { + #[error("empty response")] + Empty, + #[error("audio item is non-playable when it should be")] + NonPlayable, +} diff --git a/metadata/src/external_id.rs b/metadata/src/external_id.rs new file mode 100644 index 00000000..b310200a --- /dev/null +++ b/metadata/src/external_id.rs @@ -0,0 +1,33 @@ +use std::{fmt::Debug, ops::Deref}; + +use crate::util::from_repeated_message; + +use librespot_protocol as protocol; +use protocol::metadata::ExternalId as ExternalIdMessage; + +#[derive(Debug, Clone)] +pub struct ExternalId { + pub external_type: String, + pub id: String, // this can be anything from a URL to a ISRC, EAN or UPC +} + +#[derive(Debug, Clone)] +pub struct ExternalIds(pub Vec); + +impl Deref for ExternalIds { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&ExternalIdMessage> for ExternalId { + fn from(external_id: &ExternalIdMessage) -> Self { + Self { + external_type: external_id.get_field_type().to_owned(), + id: external_id.get_id().to_owned(), + } + } +} + +from_repeated_message!(ExternalIdMessage, ExternalIds); diff --git a/metadata/src/image.rs b/metadata/src/image.rs new file mode 100644 index 00000000..495158d6 --- /dev/null +++ b/metadata/src/image.rs @@ -0,0 +1,101 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use crate::util::{from_repeated_message, try_from_repeated_message}; + +use librespot_core::{FileId, SpotifyId}; + +use librespot_protocol as protocol; +use protocol::metadata::Image as ImageMessage; +pub use protocol::metadata::Image_Size as ImageSize; +use protocol::playlist4_external::PictureSize as PictureSizeMessage; +use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage; + +#[derive(Debug, Clone)] +pub struct Image { + pub id: FileId, + pub size: ImageSize, + pub width: i32, + pub height: i32, +} + +#[derive(Debug, Clone)] +pub struct Images(pub Vec); + +impl Deref for Images { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct PictureSize { + pub target_name: String, + pub url: String, +} + +#[derive(Debug, Clone)] +pub struct PictureSizes(pub Vec); + +impl Deref for PictureSizes { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct TranscodedPicture { + pub target_name: String, + pub uri: SpotifyId, +} + +#[derive(Debug, Clone)] +pub struct TranscodedPictures(pub Vec); + +impl Deref for TranscodedPictures { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&ImageMessage> for Image { + fn from(image: &ImageMessage) -> Self { + Self { + id: image.into(), + size: image.get_size(), + width: image.get_width(), + height: image.get_height(), + } + } +} + +from_repeated_message!(ImageMessage, Images); + +impl From<&PictureSizeMessage> for PictureSize { + fn from(size: &PictureSizeMessage) -> Self { + Self { + target_name: size.get_target_name().to_owned(), + url: size.get_url().to_owned(), + } + } +} + +from_repeated_message!(PictureSizeMessage, PictureSizes); + +impl TryFrom<&TranscodedPictureMessage> for TranscodedPicture { + type Error = librespot_core::Error; + fn try_from(picture: &TranscodedPictureMessage) -> Result { + Ok(Self { + target_name: picture.get_target_name().to_owned(), + uri: picture.try_into()?, + }) + } +} + +try_from_repeated_message!(TranscodedPictureMessage, TranscodedPictures); diff --git a/metadata/src/lib.rs b/metadata/src/lib.rs index 435633ad..577af387 100644 --- a/metadata/src/lib.rs +++ b/metadata/src/lib.rs @@ -1,534 +1,56 @@ -#![allow(clippy::unused_io_amount)] - #[macro_use] extern crate log; #[macro_use] extern crate async_trait; -pub mod cover; -use std::collections::HashMap; -use std::string::FromUtf8Error; - -use librespot_core::mercury::MercuryError; -use librespot_core::session::Session; -use librespot_core::spotify_id::{FileId, SpotifyAudioType, SpotifyId, SpotifyIdError}; -use librespot_protocol as protocol; use protobuf::Message; -pub use crate::protocol::metadata::AudioFile_Format as FileFormat; +use librespot_core::{Error, Session, SpotifyId}; -fn countrylist_contains(list: &str, country: &str) -> bool { - list.chunks(2).any(|cc| cc == country) -} +pub mod album; +pub mod artist; +pub mod audio; +pub mod availability; +pub mod content_rating; +pub mod copyright; +pub mod episode; +pub mod error; +pub mod external_id; +pub mod image; +pub mod playlist; +mod request; +pub mod restriction; +pub mod sale_period; +pub mod show; +pub mod track; +mod util; +pub mod video; -fn parse_restrictions<'s, I>(restrictions: I, country: &str, catalogue: &str) -> bool -where - I: IntoIterator, -{ - let mut forbidden = "".to_string(); - let mut has_forbidden = false; +pub use error::MetadataError; +use request::RequestResult; - let mut allowed = "".to_string(); - let mut has_allowed = false; - - let rs = restrictions - .into_iter() - .filter(|r| r.get_catalogue_str().contains(&catalogue.to_owned())); - - for r in rs { - if r.has_countries_forbidden() { - forbidden.push_str(r.get_countries_forbidden()); - has_forbidden = true; - } - - if r.has_countries_allowed() { - allowed.push_str(r.get_countries_allowed()); - has_allowed = true; - } - } - - (has_forbidden || has_allowed) - && (!has_forbidden || !countrylist_contains(forbidden.as_str(), country)) - && (!has_allowed || countrylist_contains(allowed.as_str(), country)) -} - -// A wrapper with fields the player needs -#[derive(Debug, Clone)] -pub struct AudioItem { - pub id: SpotifyId, - pub uri: String, - pub files: HashMap, - pub name: String, - pub duration: i32, - pub available: bool, - pub alternatives: Option>, -} - -impl AudioItem { - pub async fn get_audio_item(session: &Session, id: SpotifyId) -> Result { - match id.audio_type { - SpotifyAudioType::Track => Track::get_audio_item(session, id).await, - SpotifyAudioType::Podcast => Episode::get_audio_item(session, id).await, - SpotifyAudioType::NonPlayable => Err(MercuryError), - } - } -} - -#[async_trait] -trait AudioFiles { - async fn get_audio_item(session: &Session, id: SpotifyId) -> Result; -} - -#[async_trait] -impl AudioFiles for Track { - async fn get_audio_item(session: &Session, id: SpotifyId) -> Result { - match id.to_base62() { - Err(e) => { - warn!("Invalid Track SpotifyId: {}", e); - Err(MercuryError) - } - Ok(uri) => { - let item = Self::get(session, id).await?; - Ok(AudioItem { - id, - uri: format!("spotify:track:{}", uri), - files: item.files, - name: item.name, - duration: item.duration, - available: item.available, - alternatives: Some(item.alternatives), - }) - } - } - } -} - -#[async_trait] -impl AudioFiles for Episode { - async fn get_audio_item(session: &Session, id: SpotifyId) -> Result { - match id.to_base62() { - Err(e) => { - warn!("Invalid Episode SpotifyId: {}", e); - Err(MercuryError) - } - Ok(uri) => { - let item = Self::get(session, id).await?; - Ok(AudioItem { - id, - uri: format!("spotify:episode:{}", uri), - files: item.files, - name: item.name, - duration: item.duration, - available: item.available, - alternatives: None, - }) - } - } - } -} +pub use album::Album; +pub use artist::Artist; +pub use episode::Episode; +pub use playlist::Playlist; +pub use show::Show; +pub use track::Track; #[async_trait] pub trait Metadata: Send + Sized + 'static { type Message: protobuf::Message; - fn request_url(id: SpotifyId) -> Result; - fn parse(msg: &Self::Message, session: &Session) -> Result; + // Request a protobuf + async fn request(session: &Session, id: SpotifyId) -> RequestResult; - async fn get(session: &Session, id: SpotifyId) -> Result { - match Self::request_url(id) { - Err(e) => { - warn!("Invalid SpotifyId: {}", e); - Err(MercuryError) - } - Ok(uri) => { - let response = session.mercury().get(uri).await?; - match response.payload.first() { - None => { - warn!("Empty payload"); - Err(MercuryError) - } - Some(data) => match Self::Message::parse_from_bytes(data) { - Err(e) => { - warn!("Error parsing message from bytes: {}", e); - Err(MercuryError) - } - Ok(msg) => match Self::parse(&msg, session) { - Err(e) => { - warn!("Error parsing message: {:?}", e); - Err(MercuryError) - } - Ok(parsed_msg) => Ok(parsed_msg), - }, - }, - } - } - } - } -} - -#[derive(Debug, Clone)] -pub struct Track { - pub id: SpotifyId, - pub name: String, - pub duration: i32, - pub album: SpotifyId, - pub artists: Vec, - pub files: HashMap, - pub alternatives: Vec, - pub available: bool, -} - -#[derive(Debug, Clone)] -pub struct Album { - pub id: SpotifyId, - pub name: String, - pub artists: Vec, - pub tracks: Vec, - pub covers: Vec, -} - -#[derive(Debug, Clone)] -pub struct Episode { - pub id: SpotifyId, - pub name: String, - pub external_url: String, - pub duration: i32, - pub language: String, - pub show: SpotifyId, - pub files: HashMap, - pub covers: Vec, - pub available: bool, - pub explicit: bool, -} - -#[derive(Debug, Clone)] -pub struct Show { - pub id: SpotifyId, - pub name: String, - pub publisher: String, - pub episodes: Vec, - pub covers: Vec, -} - -#[derive(Debug, Clone)] -pub struct Playlist { - pub revision: Vec, - pub user: String, - pub name: String, - pub tracks: Vec, -} - -#[derive(Debug, Clone)] -pub struct Artist { - pub id: SpotifyId, - pub name: String, - pub top_tracks: Vec, -} - -impl Metadata for Track { - type Message = protocol::metadata::Track; - - fn request_url(id: SpotifyId) -> Result { - let id = id.to_base16()?; - Ok(format!("hm://metadata/3/track/{}", id)) + // Request a metadata struct + async fn get(session: &Session, id: SpotifyId) -> Result { + let response = Self::request(session, id).await?; + let msg = Self::Message::parse_from_bytes(&response)?; + trace!("Received metadata: {:#?}", msg); + Self::parse(&msg, id) } - fn parse(msg: &Self::Message, session: &Session) -> Result { - let country = session.country(); - - let artists = msg - .get_artist() - .iter() - .filter_map(|artist| { - if artist.has_gid() { - SpotifyId::from_raw(artist.get_gid()).ok() - } else { - None - } - }) - .collect(); - - let files = msg - .get_file() - .iter() - .filter_map(|file| { - if file.has_file_id() { - let mut dst = [0u8; 20]; - dst.clone_from_slice(file.get_file_id()); - Some((file.get_format(), FileId(dst))) - } else { - None - } - }) - .collect(); - - Ok(Track { - id: SpotifyId::from_raw(msg.get_gid())?, - name: msg.get_name().to_owned(), - duration: msg.get_duration(), - album: SpotifyId::from_raw(msg.get_album().get_gid())?, - artists, - files, - alternatives: msg - .get_alternative() - .iter() - .filter_map(|alt| SpotifyId::from_raw(alt.get_gid()).ok()) - .collect(), - available: parse_restrictions(msg.get_restriction(), &country, "premium"), - }) - } -} - -impl Metadata for Album { - type Message = protocol::metadata::Album; - - fn request_url(id: SpotifyId) -> Result { - let id = id.to_base16()?; - Ok(format!("hm://metadata/3/album/{}", id)) - } - - fn parse(msg: &Self::Message, _: &Session) -> Result { - let artists = msg - .get_artist() - .iter() - .filter_map(|artist| { - if artist.has_gid() { - SpotifyId::from_raw(artist.get_gid()).ok() - } else { - None - } - }) - .collect(); - - let tracks = msg - .get_disc() - .iter() - .flat_map(|disc| disc.get_track()) - .filter_map(|track| { - if track.has_gid() { - SpotifyId::from_raw(track.get_gid()).ok() - } else { - None - } - }) - .collect(); - - let covers = msg - .get_cover_group() - .get_image() - .iter() - .filter_map(|image| { - if image.has_file_id() { - let mut dst = [0u8; 20]; - dst.clone_from_slice(image.get_file_id()); - Some(FileId(dst)) - } else { - None - } - }) - .collect(); - - Ok(Album { - id: SpotifyId::from_raw(msg.get_gid())?, - name: msg.get_name().to_owned(), - artists, - tracks, - covers, - }) - } -} - -impl Metadata for Playlist { - type Message = protocol::playlist4changes::SelectedListContent; - - fn request_url(id: SpotifyId) -> Result { - let id = id.to_base62()?; - Ok(format!("hm://playlist/v2/playlist/{}", id)) - } - - fn parse(msg: &Self::Message, _: &Session) -> Result { - let tracks = msg - .get_contents() - .get_items() - .iter() - .filter_map(|item| { - let uri_split = item.get_uri().split(':'); - let uri_parts: Vec<&str> = uri_split.collect(); - SpotifyId::from_base62(uri_parts[2]).ok() - }) - .collect::>(); - - if tracks.len() != msg.get_length() as usize { - warn!( - "Got {} tracks, but the playlist should contain {} tracks.", - tracks.len(), - msg.get_length() - ); - } - - Ok(Playlist { - revision: msg.get_revision().to_vec(), - name: msg.get_attributes().get_name().to_owned(), - tracks, - user: msg.get_owner_username().to_string(), - }) - } -} - -impl Metadata for Artist { - type Message = protocol::metadata::Artist; - - fn request_url(id: SpotifyId) -> Result { - let id = id.to_base16()?; - Ok(format!("hm://metadata/3/artist/{}", id)) - } - - fn parse(msg: &Self::Message, session: &Session) -> Result { - let country = session.country(); - - let top_tracks: Vec = match msg - .get_top_track() - .iter() - .find(|tt| !tt.has_country() || countrylist_contains(tt.get_country(), &country)) - { - Some(tracks) => tracks - .get_track() - .iter() - .filter_map(|track| { - if track.has_gid() { - SpotifyId::from_raw(track.get_gid()).ok() - } else { - None - } - }) - .collect(), - None => Vec::new(), - }; - - Ok(Artist { - id: SpotifyId::from_raw(msg.get_gid())?, - name: msg.get_name().to_owned(), - top_tracks, - }) - } -} - -// Podcast -impl Metadata for Episode { - type Message = protocol::metadata::Episode; - - fn request_url(id: SpotifyId) -> Result { - let id = id.to_base16()?; - Ok(format!("hm://metadata/3/episode/{}", id)) - } - - fn parse(msg: &Self::Message, session: &Session) -> Result { - let country = session.country(); - - let files = msg - .get_file() - .iter() - .filter(|file| file.has_file_id()) - .map(|file| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(file.get_file_id()); - (file.get_format(), FileId(dst)) - }) - .collect(); - - let covers = msg - .get_covers() - .get_image() - .iter() - .filter(|image| image.has_file_id()) - .map(|image| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(image.get_file_id()); - FileId(dst) - }) - .collect(); - - Ok(Episode { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - external_url: msg.get_external_url().to_owned(), - duration: msg.get_duration().to_owned(), - language: msg.get_language().to_owned(), - show: SpotifyId::from_raw(msg.get_show().get_gid()).unwrap(), - covers, - files, - available: parse_restrictions(msg.get_restriction(), &country, "premium"), - explicit: msg.get_explicit().to_owned(), - }) - } -} - -impl Metadata for Show { - type Message = protocol::metadata::Show; - - fn request_url(id: SpotifyId) -> Result { - let id = id.to_base16()?; - Ok(format!("hm://metadata/3/show/{}", id)) - } - - fn parse(msg: &Self::Message, _: &Session) -> Result { - let episodes = msg - .get_episode() - .iter() - .filter_map(|episode| { - if episode.has_gid() { - SpotifyId::from_raw(episode.get_gid()).ok() - } else { - None - } - }) - .collect(); - - let covers = msg - .get_covers() - .get_image() - .iter() - .filter(|image| image.has_file_id()) - .map(|image| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(image.get_file_id()); - FileId(dst) - }) - .collect(); - - Ok(Show { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - publisher: msg.get_publisher().to_owned(), - episodes, - covers, - }) - } -} - -struct StrChunks<'s>(&'s str, usize); - -trait StrChunksExt { - fn chunks(&self, size: usize) -> StrChunks; -} - -impl StrChunksExt for str { - fn chunks(&self, size: usize) -> StrChunks { - StrChunks(self, size) - } -} - -impl<'s> Iterator for StrChunks<'s> { - type Item = &'s str; - fn next(&mut self) -> Option<&'s str> { - let &mut StrChunks(data, size) = self; - if data.is_empty() { - None - } else { - let ret = Some(&data[..size]); - self.0 = &data[size..]; - ret - } - } + fn parse(msg: &Self::Message, _: SpotifyId) -> Result; } diff --git a/metadata/src/playlist/annotation.rs b/metadata/src/playlist/annotation.rs new file mode 100644 index 00000000..fd8863cf --- /dev/null +++ b/metadata/src/playlist/annotation.rs @@ -0,0 +1,87 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; + +use protobuf::Message; + +use crate::{ + image::TranscodedPictures, + request::{MercuryRequest, RequestResult}, + Metadata, +}; + +use librespot_core::{Error, Session, SpotifyId}; + +use librespot_protocol as protocol; +pub use protocol::playlist_annotate3::AbuseReportState; + +#[derive(Debug, Clone)] +pub struct PlaylistAnnotation { + pub description: String, + pub picture: String, + pub transcoded_pictures: TranscodedPictures, + pub has_abuse_reporting: bool, + pub abuse_report_state: AbuseReportState, +} + +#[async_trait] +impl Metadata for PlaylistAnnotation { + type Message = protocol::playlist_annotate3::PlaylistAnnotation; + + async fn request(session: &Session, playlist_id: SpotifyId) -> RequestResult { + let current_user = session.username(); + Self::request_for_user(session, ¤t_user, playlist_id).await + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Ok(Self { + description: msg.get_description().to_owned(), + picture: msg.get_picture().to_owned(), // TODO: is this a URL or Spotify URI? + transcoded_pictures: msg.get_transcoded_picture().try_into()?, + has_abuse_reporting: msg.get_is_abuse_reporting_enabled(), + abuse_report_state: msg.get_abuse_report_state(), + }) + } +} + +impl PlaylistAnnotation { + async fn request_for_user( + session: &Session, + username: &str, + playlist_id: SpotifyId, + ) -> RequestResult { + let uri = format!( + "hm://playlist-annotate/v1/annotation/user/{}/playlist/{}", + username, + playlist_id.to_base62()? + ); + ::request(session, &uri).await + } + + #[allow(dead_code)] + async fn get_for_user( + session: &Session, + username: &str, + playlist_id: SpotifyId, + ) -> Result { + let response = Self::request_for_user(session, username, playlist_id).await?; + let msg = ::Message::parse_from_bytes(&response)?; + Self::parse(&msg, playlist_id) + } +} + +impl MercuryRequest for PlaylistAnnotation {} + +impl TryFrom<&::Message> for PlaylistAnnotation { + type Error = librespot_core::Error; + fn try_from( + annotation: &::Message, + ) -> Result { + Ok(Self { + description: annotation.get_description().to_owned(), + picture: annotation.get_picture().to_owned(), + transcoded_pictures: annotation.get_transcoded_picture().try_into()?, + has_abuse_reporting: annotation.get_is_abuse_reporting_enabled(), + abuse_report_state: annotation.get_abuse_report_state(), + }) + } +} diff --git a/metadata/src/playlist/attribute.rs b/metadata/src/playlist/attribute.rs new file mode 100644 index 00000000..d271bc54 --- /dev/null +++ b/metadata/src/playlist/attribute.rs @@ -0,0 +1,196 @@ +use std::{ + collections::HashMap, + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use crate::{image::PictureSizes, util::from_repeated_enum}; + +use librespot_core::date::Date; + +use librespot_protocol as protocol; +use protocol::playlist4_external::FormatListAttribute as PlaylistFormatAttributeMessage; +pub use protocol::playlist4_external::ItemAttributeKind as PlaylistItemAttributeKind; +use protocol::playlist4_external::ItemAttributes as PlaylistItemAttributesMessage; +use protocol::playlist4_external::ItemAttributesPartialState as PlaylistPartialItemAttributesMessage; +pub use protocol::playlist4_external::ListAttributeKind as PlaylistAttributeKind; +use protocol::playlist4_external::ListAttributes as PlaylistAttributesMessage; +use protocol::playlist4_external::ListAttributesPartialState as PlaylistPartialAttributesMessage; +use protocol::playlist4_external::UpdateItemAttributes as PlaylistUpdateItemAttributesMessage; +use protocol::playlist4_external::UpdateListAttributes as PlaylistUpdateAttributesMessage; + +#[derive(Debug, Clone)] +pub struct PlaylistAttributes { + pub name: String, + pub description: String, + pub picture: Vec, + pub is_collaborative: bool, + pub pl3_version: String, + pub is_deleted_by_owner: bool, + pub client_id: String, + pub format: String, + pub format_attributes: PlaylistFormatAttribute, + pub picture_sizes: PictureSizes, +} + +#[derive(Debug, Clone)] +pub struct PlaylistAttributeKinds(pub Vec); + +impl Deref for PlaylistAttributeKinds { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +from_repeated_enum!(PlaylistAttributeKind, PlaylistAttributeKinds); + +#[derive(Debug, Clone)] +pub struct PlaylistFormatAttribute(pub HashMap); + +impl Deref for PlaylistFormatAttribute { + type Target = HashMap; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct PlaylistItemAttributes { + pub added_by: String, + pub timestamp: Date, + pub seen_at: Date, + pub is_public: bool, + pub format_attributes: PlaylistFormatAttribute, + pub item_id: Vec, +} + +#[derive(Debug, Clone)] +pub struct PlaylistItemAttributeKinds(pub Vec); + +impl Deref for PlaylistItemAttributeKinds { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +from_repeated_enum!(PlaylistItemAttributeKind, PlaylistItemAttributeKinds); + +#[derive(Debug, Clone)] +pub struct PlaylistPartialAttributes { + #[allow(dead_code)] + values: PlaylistAttributes, + #[allow(dead_code)] + no_value: PlaylistAttributeKinds, +} + +#[derive(Debug, Clone)] +pub struct PlaylistPartialItemAttributes { + #[allow(dead_code)] + values: PlaylistItemAttributes, + #[allow(dead_code)] + no_value: PlaylistItemAttributeKinds, +} + +#[derive(Debug, Clone)] +pub struct PlaylistUpdateAttributes { + pub new_attributes: PlaylistPartialAttributes, + pub old_attributes: PlaylistPartialAttributes, +} + +#[derive(Debug, Clone)] +pub struct PlaylistUpdateItemAttributes { + pub index: i32, + pub new_attributes: PlaylistPartialItemAttributes, + pub old_attributes: PlaylistPartialItemAttributes, +} + +impl TryFrom<&PlaylistAttributesMessage> for PlaylistAttributes { + type Error = librespot_core::Error; + fn try_from(attributes: &PlaylistAttributesMessage) -> Result { + Ok(Self { + name: attributes.get_name().to_owned(), + description: attributes.get_description().to_owned(), + picture: attributes.get_picture().to_owned(), + is_collaborative: attributes.get_collaborative(), + pl3_version: attributes.get_pl3_version().to_owned(), + is_deleted_by_owner: attributes.get_deleted_by_owner(), + client_id: attributes.get_client_id().to_owned(), + format: attributes.get_format().to_owned(), + format_attributes: attributes.get_format_attributes().into(), + picture_sizes: attributes.get_picture_size().into(), + }) + } +} + +impl From<&[PlaylistFormatAttributeMessage]> for PlaylistFormatAttribute { + fn from(attributes: &[PlaylistFormatAttributeMessage]) -> Self { + let format_attributes = attributes + .iter() + .map(|attribute| { + ( + attribute.get_key().to_owned(), + attribute.get_value().to_owned(), + ) + }) + .collect(); + + PlaylistFormatAttribute(format_attributes) + } +} + +impl TryFrom<&PlaylistItemAttributesMessage> for PlaylistItemAttributes { + type Error = librespot_core::Error; + fn try_from(attributes: &PlaylistItemAttributesMessage) -> Result { + Ok(Self { + added_by: attributes.get_added_by().to_owned(), + timestamp: Date::from_timestamp_ms(attributes.get_timestamp())?, + seen_at: Date::from_timestamp_ms(attributes.get_seen_at())?, + is_public: attributes.get_public(), + format_attributes: attributes.get_format_attributes().into(), + item_id: attributes.get_item_id().to_owned(), + }) + } +} +impl TryFrom<&PlaylistPartialAttributesMessage> for PlaylistPartialAttributes { + type Error = librespot_core::Error; + fn try_from(attributes: &PlaylistPartialAttributesMessage) -> Result { + Ok(Self { + values: attributes.get_values().try_into()?, + no_value: attributes.get_no_value().into(), + }) + } +} + +impl TryFrom<&PlaylistPartialItemAttributesMessage> for PlaylistPartialItemAttributes { + type Error = librespot_core::Error; + fn try_from(attributes: &PlaylistPartialItemAttributesMessage) -> Result { + Ok(Self { + values: attributes.get_values().try_into()?, + no_value: attributes.get_no_value().into(), + }) + } +} + +impl TryFrom<&PlaylistUpdateAttributesMessage> for PlaylistUpdateAttributes { + type Error = librespot_core::Error; + fn try_from(update: &PlaylistUpdateAttributesMessage) -> Result { + Ok(Self { + new_attributes: update.get_new_attributes().try_into()?, + old_attributes: update.get_old_attributes().try_into()?, + }) + } +} + +impl TryFrom<&PlaylistUpdateItemAttributesMessage> for PlaylistUpdateItemAttributes { + type Error = librespot_core::Error; + fn try_from(update: &PlaylistUpdateItemAttributesMessage) -> Result { + Ok(Self { + index: update.get_index(), + new_attributes: update.get_new_attributes().try_into()?, + old_attributes: update.get_old_attributes().try_into()?, + }) + } +} diff --git a/metadata/src/playlist/diff.rs b/metadata/src/playlist/diff.rs new file mode 100644 index 00000000..4e40d2a5 --- /dev/null +++ b/metadata/src/playlist/diff.rs @@ -0,0 +1,29 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, +}; + +use super::operation::PlaylistOperations; + +use librespot_core::SpotifyId; + +use librespot_protocol as protocol; +use protocol::playlist4_external::Diff as DiffMessage; + +#[derive(Debug, Clone)] +pub struct PlaylistDiff { + pub from_revision: SpotifyId, + pub operations: PlaylistOperations, + pub to_revision: SpotifyId, +} + +impl TryFrom<&DiffMessage> for PlaylistDiff { + type Error = librespot_core::Error; + fn try_from(diff: &DiffMessage) -> Result { + Ok(Self { + from_revision: diff.get_from_revision().try_into()?, + operations: diff.get_ops().try_into()?, + to_revision: diff.get_to_revision().try_into()?, + }) + } +} diff --git a/metadata/src/playlist/item.rs b/metadata/src/playlist/item.rs new file mode 100644 index 00000000..20f94a0b --- /dev/null +++ b/metadata/src/playlist/item.rs @@ -0,0 +1,105 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use crate::util::try_from_repeated_message; + +use super::{ + attribute::{PlaylistAttributes, PlaylistItemAttributes}, + permission::Capabilities, +}; + +use librespot_core::{date::Date, SpotifyId}; + +use librespot_protocol as protocol; +use protocol::playlist4_external::Item as PlaylistItemMessage; +use protocol::playlist4_external::ListItems as PlaylistItemsMessage; +use protocol::playlist4_external::MetaItem as PlaylistMetaItemMessage; + +#[derive(Debug, Clone)] +pub struct PlaylistItem { + pub id: SpotifyId, + pub attributes: PlaylistItemAttributes, +} + +#[derive(Debug, Clone)] +pub struct PlaylistItems(pub Vec); + +impl Deref for PlaylistItems { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct PlaylistItemList { + pub position: i32, + pub is_truncated: bool, + pub items: PlaylistItems, + pub meta_items: PlaylistMetaItems, +} + +#[derive(Debug, Clone)] +pub struct PlaylistMetaItem { + pub revision: SpotifyId, + pub attributes: PlaylistAttributes, + pub length: i32, + pub timestamp: Date, + pub owner_username: String, + pub has_abuse_reporting: bool, + pub capabilities: Capabilities, +} + +#[derive(Debug, Clone)] +pub struct PlaylistMetaItems(pub Vec); + +impl Deref for PlaylistMetaItems { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom<&PlaylistItemMessage> for PlaylistItem { + type Error = librespot_core::Error; + fn try_from(item: &PlaylistItemMessage) -> Result { + Ok(Self { + id: item.try_into()?, + attributes: item.get_attributes().try_into()?, + }) + } +} + +try_from_repeated_message!(PlaylistItemMessage, PlaylistItems); + +impl TryFrom<&PlaylistItemsMessage> for PlaylistItemList { + type Error = librespot_core::Error; + fn try_from(list_items: &PlaylistItemsMessage) -> Result { + Ok(Self { + position: list_items.get_pos(), + is_truncated: list_items.get_truncated(), + items: list_items.get_items().try_into()?, + meta_items: list_items.get_meta_items().try_into()?, + }) + } +} + +impl TryFrom<&PlaylistMetaItemMessage> for PlaylistMetaItem { + type Error = librespot_core::Error; + fn try_from(item: &PlaylistMetaItemMessage) -> Result { + Ok(Self { + revision: item.try_into()?, + attributes: item.get_attributes().try_into()?, + length: item.get_length(), + timestamp: Date::from_timestamp_ms(item.get_timestamp())?, + owner_username: item.get_owner_username().to_owned(), + has_abuse_reporting: item.get_abuse_reporting_enabled(), + capabilities: item.get_capabilities().into(), + }) + } +} + +try_from_repeated_message!(PlaylistMetaItemMessage, PlaylistMetaItems); diff --git a/metadata/src/playlist/list.rs b/metadata/src/playlist/list.rs new file mode 100644 index 00000000..a8ef677b --- /dev/null +++ b/metadata/src/playlist/list.rs @@ -0,0 +1,229 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use protobuf::Message; + +use crate::{ + request::{MercuryRequest, RequestResult}, + util::{from_repeated_enum, try_from_repeated_message}, + Metadata, +}; + +use super::{ + attribute::PlaylistAttributes, diff::PlaylistDiff, item::PlaylistItemList, + permission::Capabilities, +}; + +use librespot_core::{ + date::Date, + spotify_id::{NamedSpotifyId, SpotifyId}, + Error, Session, +}; + +use librespot_protocol as protocol; +use protocol::playlist4_external::GeoblockBlockingType as Geoblock; + +#[derive(Debug, Clone)] +pub struct Geoblocks(Vec); + +impl Deref for Geoblocks { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct Playlist { + pub id: NamedSpotifyId, + pub revision: Vec, + pub length: i32, + pub attributes: PlaylistAttributes, + pub contents: PlaylistItemList, + pub diff: Option, + pub sync_result: Option, + pub resulting_revisions: Playlists, + pub has_multiple_heads: bool, + pub is_up_to_date: bool, + pub nonces: Vec, + pub timestamp: Date, + pub has_abuse_reporting: bool, + pub capabilities: Capabilities, + pub geoblocks: Geoblocks, +} + +#[derive(Debug, Clone)] +pub struct Playlists(pub Vec); + +impl Deref for Playlists { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct RootPlaylist(pub SelectedListContent); + +impl Deref for RootPlaylist { + type Target = SelectedListContent; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct SelectedListContent { + pub revision: Vec, + pub length: i32, + pub attributes: PlaylistAttributes, + pub contents: PlaylistItemList, + pub diff: Option, + pub sync_result: Option, + pub resulting_revisions: Playlists, + pub has_multiple_heads: bool, + pub is_up_to_date: bool, + pub nonces: Vec, + pub timestamp: Date, + pub owner_username: String, + pub has_abuse_reporting: bool, + pub capabilities: Capabilities, + pub geoblocks: Geoblocks, +} + +impl Playlist { + #[allow(dead_code)] + async fn request_for_user( + session: &Session, + username: &str, + playlist_id: SpotifyId, + ) -> RequestResult { + let uri = format!( + "hm://playlist/user/{}/playlist/{}", + username, + playlist_id.to_base62()? + ); + ::request(session, &uri).await + } + + #[allow(dead_code)] + pub async fn get_for_user( + session: &Session, + username: &str, + playlist_id: SpotifyId, + ) -> Result { + let response = Self::request_for_user(session, username, playlist_id).await?; + let msg = ::Message::parse_from_bytes(&response)?; + Self::parse(&msg, playlist_id) + } + + pub fn tracks(&self) -> Vec { + let tracks = self + .contents + .items + .iter() + .map(|item| item.id) + .collect::>(); + + let length = tracks.len(); + let expected_length = self.length as usize; + if length != expected_length { + warn!( + "Got {} tracks, but the list should contain {} tracks.", + length, expected_length, + ); + } + + tracks + } + + pub fn name(&self) -> &str { + &self.attributes.name + } +} + +impl MercuryRequest for Playlist {} + +#[async_trait] +impl Metadata for Playlist { + type Message = protocol::playlist4_external::SelectedListContent; + + async fn request(session: &Session, playlist_id: SpotifyId) -> RequestResult { + let uri = format!("hm://playlist/v2/playlist/{}", playlist_id.to_base62()?); + ::request(session, &uri).await + } + + fn parse(msg: &Self::Message, id: SpotifyId) -> Result { + // the playlist proto doesn't contain the id so we decorate it + let playlist = SelectedListContent::try_from(msg)?; + let id = NamedSpotifyId::from_spotify_id(id, playlist.owner_username); + + Ok(Self { + id, + revision: playlist.revision, + length: playlist.length, + attributes: playlist.attributes, + contents: playlist.contents, + diff: playlist.diff, + sync_result: playlist.sync_result, + resulting_revisions: playlist.resulting_revisions, + has_multiple_heads: playlist.has_multiple_heads, + is_up_to_date: playlist.is_up_to_date, + nonces: playlist.nonces, + timestamp: playlist.timestamp, + has_abuse_reporting: playlist.has_abuse_reporting, + capabilities: playlist.capabilities, + geoblocks: playlist.geoblocks, + }) + } +} + +impl MercuryRequest for RootPlaylist {} + +impl RootPlaylist { + #[allow(dead_code)] + async fn request_for_user(session: &Session, username: &str) -> RequestResult { + let uri = format!("hm://playlist/user/{}/rootlist", username,); + ::request(session, &uri).await + } + + #[allow(dead_code)] + pub async fn get_root_for_user(session: &Session, username: &str) -> Result { + let response = Self::request_for_user(session, username).await?; + let msg = protocol::playlist4_external::SelectedListContent::parse_from_bytes(&response)?; + Ok(Self(SelectedListContent::try_from(&msg)?)) + } +} + +impl TryFrom<&::Message> for SelectedListContent { + type Error = librespot_core::Error; + fn try_from(playlist: &::Message) -> Result { + Ok(Self { + revision: playlist.get_revision().to_owned(), + length: playlist.get_length(), + attributes: playlist.get_attributes().try_into()?, + contents: playlist.get_contents().try_into()?, + diff: playlist.diff.as_ref().map(TryInto::try_into).transpose()?, + sync_result: playlist + .sync_result + .as_ref() + .map(TryInto::try_into) + .transpose()?, + resulting_revisions: playlist.get_resulting_revisions().try_into()?, + has_multiple_heads: playlist.get_multiple_heads(), + is_up_to_date: playlist.get_up_to_date(), + nonces: playlist.get_nonces().into(), + timestamp: Date::from_timestamp_ms(playlist.get_timestamp())?, + owner_username: playlist.get_owner_username().to_owned(), + has_abuse_reporting: playlist.get_abuse_reporting_enabled(), + capabilities: playlist.get_capabilities().into(), + geoblocks: playlist.get_geoblock().into(), + }) + } +} + +from_repeated_enum!(Geoblock, Geoblocks); +try_from_repeated_message!(Vec, Playlists); diff --git a/metadata/src/playlist/mod.rs b/metadata/src/playlist/mod.rs new file mode 100644 index 00000000..d2b66731 --- /dev/null +++ b/metadata/src/playlist/mod.rs @@ -0,0 +1,10 @@ +pub mod annotation; +pub mod attribute; +pub mod diff; +pub mod item; +pub mod list; +pub mod operation; +pub mod permission; + +pub use annotation::PlaylistAnnotation; +pub use list::Playlist; diff --git a/metadata/src/playlist/operation.rs b/metadata/src/playlist/operation.rs new file mode 100644 index 00000000..fe33d0dc --- /dev/null +++ b/metadata/src/playlist/operation.rs @@ -0,0 +1,113 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use crate::{ + playlist::{ + attribute::{PlaylistUpdateAttributes, PlaylistUpdateItemAttributes}, + item::PlaylistItems, + }, + util::try_from_repeated_message, +}; + +use librespot_protocol as protocol; +use protocol::playlist4_external::Add as PlaylistAddMessage; +use protocol::playlist4_external::Mov as PlaylistMoveMessage; +use protocol::playlist4_external::Op as PlaylistOperationMessage; +pub use protocol::playlist4_external::Op_Kind as PlaylistOperationKind; +use protocol::playlist4_external::Rem as PlaylistRemoveMessage; + +#[derive(Debug, Clone)] +pub struct PlaylistOperation { + pub kind: PlaylistOperationKind, + pub add: PlaylistOperationAdd, + pub rem: PlaylistOperationRemove, + pub mov: PlaylistOperationMove, + pub update_item_attributes: PlaylistUpdateItemAttributes, + pub update_list_attributes: PlaylistUpdateAttributes, +} + +#[derive(Debug, Clone)] +pub struct PlaylistOperations(pub Vec); + +impl Deref for PlaylistOperations { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct PlaylistOperationAdd { + pub from_index: i32, + pub items: PlaylistItems, + pub add_last: bool, + pub add_first: bool, +} + +#[derive(Debug, Clone)] +pub struct PlaylistOperationMove { + pub from_index: i32, + pub length: i32, + pub to_index: i32, +} + +#[derive(Debug, Clone)] +pub struct PlaylistOperationRemove { + pub from_index: i32, + pub length: i32, + pub items: PlaylistItems, + pub has_items_as_key: bool, +} + +impl TryFrom<&PlaylistOperationMessage> for PlaylistOperation { + type Error = librespot_core::Error; + fn try_from(operation: &PlaylistOperationMessage) -> Result { + Ok(Self { + kind: operation.get_kind(), + add: operation.get_add().try_into()?, + rem: operation.get_rem().try_into()?, + mov: operation.get_mov().into(), + update_item_attributes: operation.get_update_item_attributes().try_into()?, + update_list_attributes: operation.get_update_list_attributes().try_into()?, + }) + } +} + +try_from_repeated_message!(PlaylistOperationMessage, PlaylistOperations); + +impl TryFrom<&PlaylistAddMessage> for PlaylistOperationAdd { + type Error = librespot_core::Error; + fn try_from(add: &PlaylistAddMessage) -> Result { + Ok(Self { + from_index: add.get_from_index(), + items: add.get_items().try_into()?, + add_last: add.get_add_last(), + add_first: add.get_add_first(), + }) + } +} + +impl From<&PlaylistMoveMessage> for PlaylistOperationMove { + fn from(mov: &PlaylistMoveMessage) -> Self { + Self { + from_index: mov.get_from_index(), + length: mov.get_length(), + to_index: mov.get_to_index(), + } + } +} + +impl TryFrom<&PlaylistRemoveMessage> for PlaylistOperationRemove { + type Error = librespot_core::Error; + fn try_from(remove: &PlaylistRemoveMessage) -> Result { + Ok(Self { + from_index: remove.get_from_index(), + length: remove.get_length(), + items: remove.get_items().try_into()?, + has_items_as_key: remove.get_items_as_key(), + }) + } +} diff --git a/metadata/src/playlist/permission.rs b/metadata/src/playlist/permission.rs new file mode 100644 index 00000000..2923a636 --- /dev/null +++ b/metadata/src/playlist/permission.rs @@ -0,0 +1,42 @@ +use std::{fmt::Debug, ops::Deref}; + +use crate::util::from_repeated_enum; + +use librespot_protocol as protocol; +use protocol::playlist_permission::Capabilities as CapabilitiesMessage; +use protocol::playlist_permission::PermissionLevel; + +#[derive(Debug, Clone)] +pub struct Capabilities { + pub can_view: bool, + pub can_administrate_permissions: bool, + pub grantable_levels: PermissionLevels, + pub can_edit_metadata: bool, + pub can_edit_items: bool, + pub can_cancel_membership: bool, +} + +#[derive(Debug, Clone)] +pub struct PermissionLevels(pub Vec); + +impl Deref for PermissionLevels { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&CapabilitiesMessage> for Capabilities { + fn from(playlist: &CapabilitiesMessage) -> Self { + Self { + can_view: playlist.get_can_view(), + can_administrate_permissions: playlist.get_can_administrate_permissions(), + grantable_levels: playlist.get_grantable_level().into(), + can_edit_metadata: playlist.get_can_edit_metadata(), + can_edit_items: playlist.get_can_edit_items(), + can_cancel_membership: playlist.get_can_cancel_membership(), + } + } +} + +from_repeated_enum!(PermissionLevel, PermissionLevels); diff --git a/metadata/src/request.rs b/metadata/src/request.rs new file mode 100644 index 00000000..503dcf88 --- /dev/null +++ b/metadata/src/request.rs @@ -0,0 +1,37 @@ +use std::fmt::Write; + +use crate::MetadataError; + +use librespot_core::{Error, Session}; + +pub type RequestResult = Result; + +#[async_trait] +pub trait MercuryRequest { + async fn request(session: &Session, uri: &str) -> RequestResult { + let mut metrics_uri = uri.to_owned(); + + let separator = match metrics_uri.find('?') { + Some(_) => "&", + None => "?", + }; + let _ = write!(metrics_uri, "{}country={}", separator, session.country()); + + if let Some(product) = session.get_user_attribute("type") { + let _ = write!(metrics_uri, "&product={}", product); + } + + trace!("Requesting {}", metrics_uri); + + let request = session.mercury().get(metrics_uri)?; + let response = request.await?; + match response.payload.first() { + Some(data) => { + let data = data.to_vec().into(); + trace!("Received metadata: {:?}", data); + Ok(data) + } + None => Err(Error::unavailable(MetadataError::Empty)), + } + } +} diff --git a/metadata/src/restriction.rs b/metadata/src/restriction.rs new file mode 100644 index 00000000..279da342 --- /dev/null +++ b/metadata/src/restriction.rs @@ -0,0 +1,104 @@ +use std::{fmt::Debug, ops::Deref}; + +use crate::util::{from_repeated_enum, from_repeated_message}; + +use protocol::metadata::Restriction as RestrictionMessage; + +use librespot_protocol as protocol; +pub use protocol::metadata::Restriction_Catalogue as RestrictionCatalogue; +pub use protocol::metadata::Restriction_Type as RestrictionType; + +#[derive(Debug, Clone)] +pub struct Restriction { + pub catalogues: RestrictionCatalogues, + pub restriction_type: RestrictionType, + pub catalogue_strs: Vec, + pub countries_allowed: Option>, + pub countries_forbidden: Option>, +} + +#[derive(Debug, Clone)] +pub struct Restrictions(pub Vec); + +impl Deref for Restrictions { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct RestrictionCatalogues(pub Vec); + +impl Deref for RestrictionCatalogues { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Restriction { + fn parse_country_codes(country_codes: &str) -> Vec { + country_codes + .chunks(2) + .map(|country_code| country_code.to_owned()) + .collect() + } +} + +impl From<&RestrictionMessage> for Restriction { + fn from(restriction: &RestrictionMessage) -> Self { + let countries_allowed = if restriction.has_countries_allowed() { + Some(Self::parse_country_codes( + restriction.get_countries_allowed(), + )) + } else { + None + }; + + let countries_forbidden = if restriction.has_countries_forbidden() { + Some(Self::parse_country_codes( + restriction.get_countries_forbidden(), + )) + } else { + None + }; + + Self { + catalogues: restriction.get_catalogue().into(), + restriction_type: restriction.get_field_type(), + catalogue_strs: restriction.get_catalogue_str().to_vec(), + countries_allowed, + countries_forbidden, + } + } +} + +from_repeated_message!(RestrictionMessage, Restrictions); +from_repeated_enum!(RestrictionCatalogue, RestrictionCatalogues); + +struct StrChunks<'s>(&'s str, usize); + +trait StrChunksExt { + fn chunks(&self, size: usize) -> StrChunks; +} + +impl StrChunksExt for str { + fn chunks(&self, size: usize) -> StrChunks { + StrChunks(self, size) + } +} + +impl<'s> Iterator for StrChunks<'s> { + type Item = &'s str; + fn next(&mut self) -> Option<&'s str> { + let &mut StrChunks(data, size) = self; + if data.is_empty() { + None + } else { + let ret = Some(&data[..size]); + self.0 = &data[size..]; + ret + } + } +} diff --git a/metadata/src/sale_period.rs b/metadata/src/sale_period.rs new file mode 100644 index 00000000..b02d2368 --- /dev/null +++ b/metadata/src/sale_period.rs @@ -0,0 +1,42 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use crate::{restriction::Restrictions, util::try_from_repeated_message}; + +use librespot_core::date::Date; + +use librespot_protocol as protocol; +use protocol::metadata::SalePeriod as SalePeriodMessage; + +#[derive(Debug, Clone)] +pub struct SalePeriod { + pub restrictions: Restrictions, + pub start: Date, + pub end: Date, +} + +#[derive(Debug, Clone)] +pub struct SalePeriods(pub Vec); + +impl Deref for SalePeriods { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom<&SalePeriodMessage> for SalePeriod { + type Error = librespot_core::Error; + fn try_from(sale_period: &SalePeriodMessage) -> Result { + Ok(Self { + restrictions: sale_period.get_restriction().into(), + start: sale_period.get_start().try_into()?, + end: sale_period.get_end().try_into()?, + }) + } +} + +try_from_repeated_message!(SalePeriodMessage, SalePeriods); diff --git a/metadata/src/show.rs b/metadata/src/show.rs new file mode 100644 index 00000000..19e910d8 --- /dev/null +++ b/metadata/src/show.rs @@ -0,0 +1,74 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, +}; + +use crate::{ + availability::Availabilities, copyright::Copyrights, episode::Episodes, image::Images, + restriction::Restrictions, Metadata, RequestResult, +}; + +use librespot_core::{Error, Session, SpotifyId}; + +use librespot_protocol as protocol; +pub use protocol::metadata::Show_ConsumptionOrder as ShowConsumptionOrder; +pub use protocol::metadata::Show_MediaType as ShowMediaType; + +#[derive(Debug, Clone)] +pub struct Show { + pub id: SpotifyId, + pub name: String, + pub description: String, + pub publisher: String, + pub language: String, + pub is_explicit: bool, + pub covers: Images, + pub episodes: Episodes, + pub copyrights: Copyrights, + pub restrictions: Restrictions, + pub keywords: Vec, + pub media_type: ShowMediaType, + pub consumption_order: ShowConsumptionOrder, + pub availability: Availabilities, + pub trailer_uri: SpotifyId, + pub has_music_and_talk: bool, + pub is_audiobook: bool, +} + +#[async_trait] +impl Metadata for Show { + type Message = protocol::metadata::Show; + + async fn request(session: &Session, show_id: SpotifyId) -> RequestResult { + session.spclient().get_show_metadata(show_id).await + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Show { + type Error = librespot_core::Error; + fn try_from(show: &::Message) -> Result { + Ok(Self { + id: show.try_into()?, + name: show.get_name().to_owned(), + description: show.get_description().to_owned(), + publisher: show.get_publisher().to_owned(), + language: show.get_language().to_owned(), + is_explicit: show.get_explicit(), + covers: show.get_cover_image().get_image().into(), + episodes: show.get_episode().try_into()?, + copyrights: show.get_copyright().into(), + restrictions: show.get_restriction().into(), + keywords: show.get_keyword().to_vec(), + media_type: show.get_media_type(), + consumption_order: show.get_consumption_order(), + availability: show.get_availability().try_into()?, + trailer_uri: SpotifyId::from_uri(show.get_trailer_uri())?, + has_music_and_talk: show.get_music_and_talk(), + is_audiobook: show.get_is_audiobook(), + }) + } +} diff --git a/metadata/src/track.rs b/metadata/src/track.rs new file mode 100644 index 00000000..4ab9b2b4 --- /dev/null +++ b/metadata/src/track.rs @@ -0,0 +1,149 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use uuid::Uuid; + +use crate::{ + artist::{Artists, ArtistsWithRole}, + audio::{ + file::AudioFiles, + item::{AudioItem, AudioItemResult, InnerAudioItem}, + }, + availability::{Availabilities, UnavailabilityReason}, + content_rating::ContentRatings, + external_id::ExternalIds, + restriction::Restrictions, + sale_period::SalePeriods, + util::try_from_repeated_message, + Metadata, RequestResult, +}; + +use librespot_core::{date::Date, Error, Session, SpotifyId}; +use librespot_protocol as protocol; + +#[derive(Debug, Clone)] +pub struct Track { + pub id: SpotifyId, + pub name: String, + pub album: SpotifyId, + pub artists: Artists, + pub number: i32, + pub disc_number: i32, + pub duration: i32, + pub popularity: i32, + pub is_explicit: bool, + pub external_ids: ExternalIds, + pub restrictions: Restrictions, + pub files: AudioFiles, + pub alternatives: Tracks, + pub sale_periods: SalePeriods, + pub previews: AudioFiles, + pub tags: Vec, + pub earliest_live_timestamp: Date, + pub has_lyrics: bool, + pub availability: Availabilities, + pub licensor: Uuid, + pub language_of_performance: Vec, + pub content_ratings: ContentRatings, + pub original_title: String, + pub version_title: String, + pub artists_with_role: ArtistsWithRole, +} + +#[derive(Debug, Clone)] +pub struct Tracks(pub Vec); + +impl Deref for Tracks { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait] +impl InnerAudioItem for Track { + async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult { + let track = Self::get(session, id).await?; + let alternatives = { + if track.alternatives.is_empty() { + None + } else { + Some(track.alternatives.clone()) + } + }; + + // TODO: check meaning of earliest_live_timestamp in + let availability = if Date::now_utc() < track.earliest_live_timestamp { + Err(UnavailabilityReason::Embargo) + } else { + Self::available_for_user( + &session.user_data(), + &track.availability, + &track.restrictions, + ) + }; + + Ok(AudioItem { + id, + spotify_uri: id.to_uri()?, + files: track.files, + name: track.name, + duration: track.duration, + availability, + alternatives, + is_explicit: track.is_explicit, + }) + } +} + +#[async_trait] +impl Metadata for Track { + type Message = protocol::metadata::Track; + + async fn request(session: &Session, track_id: SpotifyId) -> RequestResult { + session.spclient().get_track_metadata(track_id).await + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Track { + type Error = librespot_core::Error; + fn try_from(track: &::Message) -> Result { + Ok(Self { + id: track.try_into()?, + name: track.get_name().to_owned(), + album: track.get_album().try_into()?, + artists: track.get_artist().try_into()?, + number: track.get_number(), + disc_number: track.get_disc_number(), + duration: track.get_duration(), + popularity: track.get_popularity(), + is_explicit: track.get_explicit(), + external_ids: track.get_external_id().into(), + restrictions: track.get_restriction().into(), + files: track.get_file().into(), + alternatives: track.get_alternative().try_into()?, + sale_periods: track.get_sale_period().try_into()?, + previews: track.get_preview().into(), + tags: track.get_tags().to_vec(), + earliest_live_timestamp: Date::from_timestamp_ms(track.get_earliest_live_timestamp())?, + has_lyrics: track.get_has_lyrics(), + availability: track.get_availability().try_into()?, + licensor: Uuid::from_slice(track.get_licensor().get_uuid()) + .unwrap_or_else(|_| Uuid::nil()), + language_of_performance: track.get_language_of_performance().to_vec(), + content_ratings: track.get_content_rating().into(), + original_title: track.get_original_title().to_owned(), + version_title: track.get_version_title().to_owned(), + artists_with_role: track.get_artist_with_role().try_into()?, + }) + } +} + +try_from_repeated_message!(::Message, Tracks); diff --git a/metadata/src/util.rs b/metadata/src/util.rs new file mode 100644 index 00000000..59142847 --- /dev/null +++ b/metadata/src/util.rs @@ -0,0 +1,39 @@ +macro_rules! from_repeated_message { + ($src:ty, $dst:ty) => { + impl From<&[$src]> for $dst { + fn from(src: &[$src]) -> Self { + let result = src.iter().map(From::from).collect(); + Self(result) + } + } + }; +} + +pub(crate) use from_repeated_message; + +macro_rules! from_repeated_enum { + ($src:ty, $dst:ty) => { + impl From<&[$src]> for $dst { + fn from(src: &[$src]) -> Self { + let result = src.iter().map(|x| <$src>::from(*x)).collect(); + Self(result) + } + } + }; +} + +pub(crate) use from_repeated_enum; + +macro_rules! try_from_repeated_message { + ($src:ty, $dst:ty) => { + impl TryFrom<&[$src]> for $dst { + type Error = librespot_core::Error; + fn try_from(src: &[$src]) -> Result { + let result: Result, _> = src.iter().map(TryFrom::try_from).collect(); + Ok(Self(result?)) + } + } + }; +} + +pub(crate) use try_from_repeated_message; diff --git a/metadata/src/video.rs b/metadata/src/video.rs new file mode 100644 index 00000000..5e883339 --- /dev/null +++ b/metadata/src/video.rs @@ -0,0 +1,20 @@ +use std::{fmt::Debug, ops::Deref}; + +use crate::util::from_repeated_message; + +use librespot_core::FileId; + +use librespot_protocol as protocol; +use protocol::metadata::VideoFile as VideoFileMessage; + +#[derive(Debug, Clone)] +pub struct VideoFiles(pub Vec); + +impl Deref for VideoFiles { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +from_repeated_message!(VideoFileMessage, VideoFiles); diff --git a/playback/Cargo.toml b/playback/Cargo.toml index aaaf3293..8e9f7019 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -18,15 +18,14 @@ path = "../metadata" version = "0.4.2" [dependencies] -futures-executor = "0.3" -futures-util = { version = "0.3", default_features = false, features = ["alloc"] } +byteorder = "1" +futures-util = "0.3" log = "0.4" parking_lot = { version = "0.12", features = ["deadlock_detection"] } -byteorder = "1.4" -shell-words = "1.0.0" -tokio = { version = "1", features = ["sync", "parking_lot"] } -zerocopy = { version = "0.6" } -thiserror = { version = "1" } +shell-words = "1.1" +thiserror = "1" +tokio = { version = "1", features = ["parking_lot", "rt", "rt-multi-thread", "sync"] } +zerocopy = "0.6" # Backends alsa = { version = "0.6", optional = true } @@ -44,9 +43,11 @@ glib = { version = "0.15", optional = true } rodio = { version = "0.15", optional = true, default-features = false } cpal = { version = "0.13", optional = true } -# Decoder -lewton = "0.10" -ogg = "0.8" +# Container and audio decoder +symphonia = { version = "0.5", default-features = false, features = ["mp3", "ogg", "vorbis"] } + +# Legacy Ogg container decoder for the passthrough decoder +ogg = { version = "0.8", optional = true } # Dithering rand = { version = "0.8", features = ["small_rng"] } @@ -61,3 +62,5 @@ rodio-backend = ["rodio", "cpal"] rodiojack-backend = ["rodio", "cpal/jack"] sdl-backend = ["sdl2"] gstreamer-backend = ["gstreamer", "gstreamer-app", "gstreamer-audio", "glib"] + +passthrough-decoder = ["ogg"] diff --git a/playback/src/audio_backend/gstreamer.rs b/playback/src/audio_backend/gstreamer.rs index 0a98846e..ad2a7591 100644 --- a/playback/src/audio_backend/gstreamer.rs +++ b/playback/src/audio_backend/gstreamer.rs @@ -141,10 +141,10 @@ impl Sink for GstreamerSink { self.appsrc.send_event(FlushStop::new(true)); self.bufferpool .set_active(true) - .map_err(|e| SinkError::OnWrite(e.to_string()))?; + .map_err(|e| SinkError::StateChange(e.to_string()))?; self.pipeline .set_state(State::Playing) - .map_err(|e| SinkError::OnWrite(e.to_string()))?; + .map_err(|e| SinkError::StateChange(e.to_string()))?; Ok(()) } @@ -153,10 +153,10 @@ impl Sink for GstreamerSink { self.appsrc.send_event(FlushStart::new()); self.pipeline .set_state(State::Paused) - .map_err(|e| SinkError::OnWrite(e.to_string()))?; + .map_err(|e| SinkError::StateChange(e.to_string()))?; self.bufferpool .set_active(false) - .map_err(|e| SinkError::OnWrite(e.to_string()))?; + .map_err(|e| SinkError::StateChange(e.to_string()))?; Ok(()) } diff --git a/playback/src/audio_backend/mod.rs b/playback/src/audio_backend/mod.rs index 6c903d22..959bf17d 100644 --- a/playback/src/audio_backend/mod.rs +++ b/playback/src/audio_backend/mod.rs @@ -13,6 +13,8 @@ pub enum SinkError { OnWrite(String), #[error("Audio Sink Error Invalid Parameters: {0}")] InvalidParams(String), + #[error("Audio Sink Error Changing State: {0}")] + StateChange(String), } pub type SinkResult = Result; @@ -71,7 +73,7 @@ macro_rules! sink_as_bytes { self.write_bytes(samples_s16.as_bytes()) } }, - AudioPacket::OggData(samples) => self.write_bytes(&samples), + AudioPacket::Raw(samples) => self.write_bytes(&samples), } } }; diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index bbc5de1a..54851d8b 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -186,6 +186,17 @@ pub fn open(host: cpal::Host, device: Option, format: AudioFormat) -> Ro } impl Sink for RodioSink { + fn start(&mut self) -> SinkResult<()> { + self.rodio_sink.play(); + Ok(()) + } + + fn stop(&mut self) -> SinkResult<()> { + self.rodio_sink.sleep_until_end(); + self.rodio_sink.pause(); + Ok(()) + } + fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> { let samples = packet .samples() diff --git a/playback/src/audio_backend/sdl.rs b/playback/src/audio_backend/sdl.rs index 9ee78e94..4e390262 100644 --- a/playback/src/audio_backend/sdl.rs +++ b/playback/src/audio_backend/sdl.rs @@ -95,7 +95,7 @@ impl Sink for SdlSink { let samples = packet .samples() .map_err(|e| SinkError::OnWrite(e.to_string()))?; - match self { + let result = match self { Self::F32(queue) => { let samples_f32: &[f32] = &converter.f64_to_f32(samples); drain_sink!(queue, AudioFormat::F32.size()); @@ -111,9 +111,8 @@ impl Sink for SdlSink { drain_sink!(queue, AudioFormat::S16.size()); queue.queue_audio(samples_s16) } - } - .map_err(SinkError::OnWrite)?; - Ok(()) + }; + result.map_err(SinkError::OnWrite) } } diff --git a/playback/src/decoder/lewton_decoder.rs b/playback/src/decoder/lewton_decoder.rs deleted file mode 100644 index bc90b992..00000000 --- a/playback/src/decoder/lewton_decoder.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult}; - -use lewton::audio::AudioReadError::AudioIsHeader; -use lewton::inside_ogg::OggStreamReader; -use lewton::samples::InterleavedSamples; -use lewton::OggReadError::NoCapturePatternFound; -use lewton::VorbisError::{BadAudio, OggError}; - -use std::io::{Read, Seek}; - -pub struct VorbisDecoder(OggStreamReader); - -impl VorbisDecoder -where - R: Read + Seek, -{ - pub fn new(input: R) -> DecoderResult> { - let reader = - OggStreamReader::new(input).map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?; - Ok(VorbisDecoder(reader)) - } -} - -impl AudioDecoder for VorbisDecoder -where - R: Read + Seek, -{ - fn seek(&mut self, absgp: u64) -> DecoderResult<()> { - self.0 - .seek_absgp_pg(absgp) - .map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?; - Ok(()) - } - - fn next_packet(&mut self) -> DecoderResult> { - loop { - match self.0.read_dec_packet_generic::>() { - Ok(Some(packet)) => return Ok(Some(AudioPacket::samples_from_f32(packet.samples))), - Ok(None) => return Ok(None), - Err(BadAudio(AudioIsHeader)) => (), - Err(OggError(NoCapturePatternFound)) => (), - Err(e) => return Err(DecoderError::LewtonDecoder(e.to_string())), - } - } - } -} diff --git a/playback/src/decoder/mod.rs b/playback/src/decoder/mod.rs index 087bba4c..f980b680 100644 --- a/playback/src/decoder/mod.rs +++ b/playback/src/decoder/mod.rs @@ -1,26 +1,30 @@ +use std::ops::Deref; + use thiserror::Error; -mod lewton_decoder; -pub use lewton_decoder::VorbisDecoder; - +#[cfg(feature = "passthrough-decoder")] mod passthrough_decoder; +#[cfg(feature = "passthrough-decoder")] pub use passthrough_decoder::PassthroughDecoder; +mod symphonia_decoder; +pub use symphonia_decoder::SymphoniaDecoder; + #[derive(Error, Debug)] pub enum DecoderError { - #[error("Lewton Decoder Error: {0}")] - LewtonDecoder(String), #[error("Passthrough Decoder Error: {0}")] PassthroughDecoder(String), + #[error("Symphonia Decoder Error: {0}")] + SymphoniaDecoder(String), } pub type DecoderResult = Result; #[derive(Error, Debug)] pub enum AudioPacketError { - #[error("Decoder OggData Error: Can't return OggData on Samples")] - OggData, - #[error("Decoder Samples Error: Can't return Samples on OggData")] + #[error("Decoder Raw Error: Can't return Raw on Samples")] + Raw, + #[error("Decoder Samples Error: Can't return Samples on Raw")] Samples, } @@ -28,25 +32,20 @@ pub type AudioPacketResult = Result; pub enum AudioPacket { Samples(Vec), - OggData(Vec), + Raw(Vec), } impl AudioPacket { - pub fn samples_from_f32(f32_samples: Vec) -> Self { - let f64_samples = f32_samples.iter().map(|sample| *sample as f64).collect(); - AudioPacket::Samples(f64_samples) - } - pub fn samples(&self) -> AudioPacketResult<&[f64]> { match self { AudioPacket::Samples(s) => Ok(s), - AudioPacket::OggData(_) => Err(AudioPacketError::OggData), + AudioPacket::Raw(_) => Err(AudioPacketError::Raw), } } - pub fn oggdata(&self) -> AudioPacketResult<&[u8]> { + pub fn raw(&self) -> AudioPacketResult<&[u8]> { match self { - AudioPacket::OggData(d) => Ok(d), + AudioPacket::Raw(d) => Ok(d), AudioPacket::Samples(_) => Err(AudioPacketError::Samples), } } @@ -54,12 +53,37 @@ impl AudioPacket { pub fn is_empty(&self) -> bool { match self { AudioPacket::Samples(s) => s.is_empty(), - AudioPacket::OggData(d) => d.is_empty(), + AudioPacket::Raw(d) => d.is_empty(), } } } -pub trait AudioDecoder { - fn seek(&mut self, absgp: u64) -> DecoderResult<()>; - fn next_packet(&mut self) -> DecoderResult>; +#[derive(Debug, Clone)] +pub struct AudioPacketPosition { + pub position_ms: u32, + pub skipped: bool, +} + +impl Deref for AudioPacketPosition { + type Target = u32; + fn deref(&self) -> &Self::Target { + &self.position_ms + } +} + +pub trait AudioDecoder { + fn seek(&mut self, position_ms: u32) -> Result; + fn next_packet(&mut self) -> DecoderResult>; +} + +impl From for librespot_core::error::Error { + fn from(err: DecoderError) -> Self { + librespot_core::error::Error::aborted(err) + } +} + +impl From for DecoderError { + fn from(err: symphonia::core::errors::Error) -> Self { + Self::SymphoniaDecoder(err.to_string()) + } } diff --git a/playback/src/decoder/passthrough_decoder.rs b/playback/src/decoder/passthrough_decoder.rs index dd8e3b32..b04b8e0d 100644 --- a/playback/src/decoder/passthrough_decoder.rs +++ b/playback/src/decoder/passthrough_decoder.rs @@ -1,8 +1,18 @@ // Passthrough decoder for librespot -use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult}; +use std::{ + io::{Read, Seek}, + time::{SystemTime, UNIX_EPOCH}, +}; + +// TODO: move this to the Symphonia Ogg demuxer use ogg::{OggReadError, Packet, PacketReader, PacketWriteEndInfo, PacketWriter}; -use std::io::{Read, Seek}; -use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult}; + +use crate::{ + metadata::audio::{AudioFileFormat, AudioFiles}, + MS_PER_PAGE, PAGES_PER_MS, +}; fn get_header(code: u8, rdr: &mut PacketReader) -> DecoderResult> where @@ -16,7 +26,7 @@ where debug!("Vorbis header type {}", &pkt_type); if pkt_type != code { - return Err(DecoderError::PassthroughDecoder("Invalid Data".to_string())); + return Err(DecoderError::PassthroughDecoder("Invalid Data".into())); } Ok(pck.data.into_boxed_slice()) @@ -36,7 +46,14 @@ pub struct PassthroughDecoder { impl PassthroughDecoder { /// Constructs a new Decoder from a given implementation of `Read + Seek`. - pub fn new(rdr: R) -> DecoderResult { + pub fn new(rdr: R, format: AudioFileFormat) -> DecoderResult { + if !AudioFiles::is_ogg_vorbis(format) { + return Err(DecoderError::PassthroughDecoder(format!( + "Passthrough decoder is not implemented for format {:?}", + format + ))); + } + let mut rdr = PacketReader::new(rdr); let since_epoch = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -65,10 +82,16 @@ impl PassthroughDecoder { bos: false, }) } + + fn position_pcm_to_ms(position_pcm: u64) -> u32 { + (position_pcm as f64 * MS_PER_PAGE) as u32 + } } impl AudioDecoder for PassthroughDecoder { - fn seek(&mut self, absgp: u64) -> DecoderResult<()> { + fn seek(&mut self, position_ms: u32) -> Result { + let absgp = (position_ms as f64 * PAGES_PER_MS) as u64; + // add an eos to previous stream if missing if self.bos && !self.eos { match self.rdr.read_packet() { @@ -101,20 +124,20 @@ impl AudioDecoder for PassthroughDecoder { .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; match pck { Some(pck) => { - self.ofsgp_page = pck.absgp_page(); - debug!("Seek to offset page {}", self.ofsgp_page); - Ok(()) + let new_page = pck.absgp_page(); + self.ofsgp_page = new_page; + debug!("Seek to offset page {}", new_page); + let new_position_ms = Self::position_pcm_to_ms(new_page); + Ok(new_position_ms) } - None => Err(DecoderError::PassthroughDecoder( - "Packet is None".to_string(), - )), + None => Err(DecoderError::PassthroughDecoder("Packet is None".into())), } } Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())), } } - fn next_packet(&mut self) -> DecoderResult> { + fn next_packet(&mut self) -> DecoderResult> { // write headers if we are (re)starting if !self.bos { self.wtr @@ -184,8 +207,15 @@ impl AudioDecoder for PassthroughDecoder { let data = self.wtr.inner_mut(); if !data.is_empty() { - let ogg_data = AudioPacket::OggData(std::mem::take(data)); - return Ok(Some(ogg_data)); + let position_ms = Self::position_pcm_to_ms(pckgp_page); + let packet_position = AudioPacketPosition { + position_ms, + skipped: false, + }; + + let ogg_data = AudioPacket::Raw(std::mem::take(data)); + + return Ok(Some((packet_position, ogg_data))); } } } diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs new file mode 100644 index 00000000..08c7b37c --- /dev/null +++ b/playback/src/decoder/symphonia_decoder.rs @@ -0,0 +1,226 @@ +use std::io; + +use symphonia::{ + core::{ + audio::SampleBuffer, + codecs::{Decoder, DecoderOptions}, + errors::Error, + formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, + io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, + meta::{StandardTagKey, Value}, + units::Time, + }, + default::{ + codecs::{Mp3Decoder, VorbisDecoder}, + formats::{Mp3Reader, OggReader}, + }, +}; + +use super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult}; + +use crate::{ + metadata::audio::{AudioFileFormat, AudioFiles}, + player::NormalisationData, + NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, +}; + +pub struct SymphoniaDecoder { + format: Box, + decoder: Box, + sample_buffer: Option>, +} + +impl SymphoniaDecoder { + pub fn new(input: R, file_format: AudioFileFormat) -> DecoderResult + where + R: MediaSource + 'static, + { + let mss_opts = MediaSourceStreamOptions { + buffer_len: librespot_audio::MINIMUM_DOWNLOAD_SIZE, + }; + let mss = MediaSourceStream::new(Box::new(input), mss_opts); + + let format_opts = FormatOptions { + enable_gapless: true, + ..Default::default() + }; + + let format: Box = if AudioFiles::is_ogg_vorbis(file_format) { + Box::new(OggReader::try_new(mss, &format_opts)?) + } else if AudioFiles::is_mp3(file_format) { + Box::new(Mp3Reader::try_new(mss, &format_opts)?) + } else { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported format: {:?}", + file_format + ))); + }; + + let track = format.default_track().ok_or_else(|| { + DecoderError::SymphoniaDecoder("Could not retrieve default track".into()) + })?; + + let decoder_opts: DecoderOptions = Default::default(); + let decoder: Box = if AudioFiles::is_ogg_vorbis(file_format) { + Box::new(VorbisDecoder::try_new(&track.codec_params, &decoder_opts)?) + } else if AudioFiles::is_mp3(file_format) { + Box::new(Mp3Decoder::try_new(&track.codec_params, &decoder_opts)?) + } else { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported decoder: {:?}", + file_format + ))); + }; + + let rate = decoder.codec_params().sample_rate.ok_or_else(|| { + DecoderError::SymphoniaDecoder("Could not retrieve sample rate".into()) + })?; + if rate != SAMPLE_RATE { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported sample rate: {}", + rate + ))); + } + + let channels = decoder.codec_params().channels.ok_or_else(|| { + DecoderError::SymphoniaDecoder("Could not retrieve channel configuration".into()) + })?; + if channels.count() != NUM_CHANNELS as usize { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported number of channels: {}", + channels + ))); + } + + Ok(Self { + format, + decoder, + + // We set the sample buffer when decoding the first full packet, + // whose duration is also the ideal sample buffer size. + sample_buffer: None, + }) + } + + pub fn normalisation_data(&mut self) -> Option { + let mut metadata = self.format.metadata(); + + // Advance to the latest metadata revision. + // None means we hit the latest. + loop { + if metadata.pop().is_none() { + break; + } + } + + let tags = metadata.current()?.tags(); + + if tags.is_empty() { + None + } else { + let mut data = NormalisationData::default(); + + for tag in tags { + if let Value::Float(value) = tag.value { + match tag.std_key { + Some(StandardTagKey::ReplayGainAlbumGain) => data.album_gain_db = value, + Some(StandardTagKey::ReplayGainAlbumPeak) => data.album_peak = value, + Some(StandardTagKey::ReplayGainTrackGain) => data.track_gain_db = value, + Some(StandardTagKey::ReplayGainTrackPeak) => data.track_peak = value, + _ => (), + } + } + } + + Some(data) + } + } + + fn ts_to_ms(&self, ts: u64) -> u32 { + let time_base = self.decoder.codec_params().time_base; + let seeked_to_ms = match time_base { + Some(time_base) => { + let time = time_base.calc_time(ts); + (time.seconds as f64 + time.frac) * 1000. + } + // Fallback in the unexpected case that the format has no base time set. + None => (ts as f64 * PAGES_PER_MS), + }; + seeked_to_ms as u32 + } +} + +impl AudioDecoder for SymphoniaDecoder { + fn seek(&mut self, position_ms: u32) -> Result { + let seconds = position_ms as u64 / 1000; + let frac = (position_ms as f64 % 1000.) / 1000.; + let time = Time::new(seconds, frac); + + // `track_id: None` implies the default track ID (of the container, not of Spotify). + let seeked_to_ts = self.format.seek( + SeekMode::Accurate, + SeekTo::Time { + time, + track_id: None, + }, + )?; + + // Seeking is a `FormatReader` operation, so the decoder cannot reliably + // know when a seek took place. Reset it to avoid audio glitches. + self.decoder.reset(); + + Ok(self.ts_to_ms(seeked_to_ts.actual_ts)) + } + + fn next_packet(&mut self) -> DecoderResult> { + let mut skipped = false; + + loop { + let packet = match self.format.next_packet() { + Ok(packet) => packet, + Err(Error::IoError(err)) => { + if err.kind() == io::ErrorKind::UnexpectedEof { + return Ok(None); + } else { + return Err(DecoderError::SymphoniaDecoder(err.to_string())); + } + } + Err(err) => { + return Err(err.into()); + } + }; + + let position_ms = self.ts_to_ms(packet.ts()); + let packet_position = AudioPacketPosition { + position_ms, + skipped, + }; + + match self.decoder.decode(&packet) { + Ok(decoded) => { + let sample_buffer = match self.sample_buffer.as_mut() { + Some(buffer) => buffer, + None => { + let spec = *decoded.spec(); + let duration = decoded.capacity() as u64; + self.sample_buffer.insert(SampleBuffer::new(duration, spec)) + } + }; + + sample_buffer.copy_interleaved_ref(decoded); + let samples = AudioPacket::Samples(sample_buffer.samples().to_vec()); + + return Ok(Some((packet_position, samples))); + } + Err(Error::DecodeError(_)) => { + // The packet failed to decode due to corrupted or invalid data, get a new + // packet and try again. + warn!("Skipping malformed audio packet at {} ms", position_ms); + skipped = true; + continue; + } + Err(err) => return Err(err.into()), + } + } + } +} diff --git a/playback/src/mixer/alsamixer.rs b/playback/src/mixer/alsamixer.rs index a930d168..52be1085 100644 --- a/playback/src/mixer/alsamixer.rs +++ b/playback/src/mixer/alsamixer.rs @@ -201,7 +201,7 @@ impl Mixer for AlsaMixer { mapped_volume = LogMapping::linear_to_mapped(mapped_volume, self.db_range); } - self.config.volume_ctrl.to_unmapped(mapped_volume) + self.config.volume_ctrl.as_unmapped(mapped_volume) } fn set_volume(&self, volume: u16) { diff --git a/playback/src/mixer/mappings.rs b/playback/src/mixer/mappings.rs index 548d0648..736b3c3f 100644 --- a/playback/src/mixer/mappings.rs +++ b/playback/src/mixer/mappings.rs @@ -3,7 +3,7 @@ use crate::player::db_to_ratio; pub trait MappedCtrl { fn to_mapped(&self, volume: u16) -> f64; - fn to_unmapped(&self, mapped_volume: f64) -> u16; + fn as_unmapped(&self, mapped_volume: f64) -> u16; fn db_range(&self) -> f64; fn set_db_range(&mut self, new_db_range: f64); @@ -49,7 +49,7 @@ impl MappedCtrl for VolumeCtrl { mapped_volume } - fn to_unmapped(&self, mapped_volume: f64) -> u16 { + fn as_unmapped(&self, mapped_volume: f64) -> u16 { // More than just an optimization, this ensures that zero mapped volume // is unmapped to non-negative real numbers (otherwise the log and cubic // equations would respectively return -inf and -1/9.) diff --git a/playback/src/mixer/softmixer.rs b/playback/src/mixer/softmixer.rs index db72659d..061f39b9 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -26,7 +26,7 @@ impl Mixer for SoftMixer { fn volume(&self) -> u16 { let mapped_volume = f64::from_bits(self.volume.load(Ordering::Relaxed)); - self.volume_ctrl.to_unmapped(mapped_volume) + self.volume_ctrl.as_unmapped(mapped_volume) } fn set_volume(&self, volume: u16) { diff --git a/playback/src/player.rs b/playback/src/player.rs index a6935010..1ebf42d7 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -1,38 +1,60 @@ -use std::cmp::max; -use std::future::Future; -use std::io::{self, Read, Seek, SeekFrom}; -use std::pin::Pin; -use std::process::exit; -use std::task::{Context, Poll}; -use std::time::{Duration, Instant}; -use std::{mem, thread}; +use std::{ + cmp::max, + collections::HashMap, + fmt, + future::Future, + io::{self, Read, Seek, SeekFrom}, + mem, + pin::Pin, + process::exit, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + thread, + time::{Duration, Instant}, +}; use byteorder::{LittleEndian, ReadBytesExt}; -use futures_util::stream::futures_unordered::FuturesUnordered; -use futures_util::{future, StreamExt, TryFutureExt}; +use futures_util::{ + future, future::FusedFuture, stream::futures_unordered::FuturesUnordered, StreamExt, + TryFutureExt, +}; +use parking_lot::Mutex; +use symphonia::core::io::MediaSource; use tokio::sync::{mpsc, oneshot}; -use crate::audio::{AudioDecrypt, AudioFile, StreamLoaderController}; -use crate::audio::{ - READ_AHEAD_BEFORE_PLAYBACK, READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK, - READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, +use crate::{ + audio::{ + AudioDecrypt, AudioFile, StreamLoaderController, READ_AHEAD_BEFORE_PLAYBACK, + READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK, + READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, + }, + audio_backend::Sink, + config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}, + convert::Converter, + core::{util::SeqGenerator, Error, Session, SpotifyId}, + decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder}, + metadata::audio::{AudioFileFormat, AudioFiles, AudioItem}, + mixer::VolumeGetter, }; -use crate::audio_backend::Sink; -use crate::config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}; -use crate::convert::Converter; -use crate::core::session::Session; -use crate::core::spotify_id::SpotifyId; -use crate::core::util::SeqGenerator; -use crate::decoder::{AudioDecoder, AudioPacket, DecoderError, PassthroughDecoder, VorbisDecoder}; -use crate::metadata::{AudioItem, FileFormat}; -use crate::mixer::VolumeGetter; -use crate::{MS_PER_PAGE, NUM_CHANNELS, PAGES_PER_MS, SAMPLES_PER_SECOND}; +#[cfg(feature = "passthrough-decoder")] +use crate::decoder::PassthroughDecoder; + +use crate::SAMPLES_PER_SECOND; const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000; pub const DB_VOLTAGE_RATIO: f64 = 20.0; pub const PCM_AT_0DBFS: f64 = 1.0; +// Spotify inserts a custom Ogg packet at the start with custom metadata values, that you would +// otherwise expect in Vorbis comments. This packet isn't well-formed and players may balk at it. +const SPOTIFY_OGG_HEADER_END: u64 = 0xa7; + +pub type PlayerResult = Result<(), Error>; + pub struct Player { commands: Option>, thread_handle: Option>, @@ -52,6 +74,7 @@ struct PlayerInternal { session: Session, config: PlayerConfig, commands: mpsc::UnboundedReceiver, + load_handles: Arc>>>, state: PlayerState, preload: PlayerPreload, @@ -66,8 +89,12 @@ struct PlayerInternal { normalisation_peak: f64, auto_normalise_as_album: bool, + + player_id: usize, } +static PLAYER_COUNTER: AtomicUsize = AtomicUsize::new(0); + enum PlayerCommand { Load { track_id: SpotifyId, @@ -86,6 +113,7 @@ enum PlayerCommand { SetSinkEventCallback(Option), EmitVolumeSetEvent(u16), SetAutoNormaliseAsAlbum(bool), + SkipExplicitContent(), } #[derive(Debug, Clone)] @@ -215,16 +243,37 @@ pub fn coefficient_to_duration(coefficient: f64) -> Duration { #[derive(Clone, Copy, Debug)] pub struct NormalisationData { - track_gain_db: f64, - track_peak: f64, - album_gain_db: f64, - album_peak: f64, + // Spotify provides these as `f32`, but audio metadata can contain up to `f64`. + // Also, this negates the need for casting during sample processing. + pub track_gain_db: f64, + pub track_peak: f64, + pub album_gain_db: f64, + pub album_peak: f64, +} + +impl Default for NormalisationData { + fn default() -> Self { + Self { + track_gain_db: 0.0, + track_peak: 1.0, + album_gain_db: 0.0, + album_peak: 1.0, + } + } } impl NormalisationData { - fn parse_from_file(mut file: T) -> io::Result { + fn parse_from_ogg(mut file: T) -> io::Result { const SPOTIFY_NORMALIZATION_HEADER_START_OFFSET: u64 = 144; - file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?; + let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?; + if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET { + error!( + "NormalisationData::parse_from_file seeking to {} but position is now {}", + SPOTIFY_NORMALIZATION_HEADER_START_OFFSET, newpos + ); + error!("Falling back to default (non-track and non-album) normalisation data."); + return Ok(NormalisationData::default()); + } let track_gain_db = file.read_f32::()? as f64; let track_peak = file.read_f32::()? as f64; @@ -355,7 +404,8 @@ impl Player { } let handle = thread::spawn(move || { - debug!("new Player[{}]", session.session_id()); + let player_id = PLAYER_COUNTER.fetch_add(1, Ordering::AcqRel); + debug!("new Player [{}]", player_id); let converter = Converter::new(config.ditherer); @@ -363,6 +413,7 @@ impl Player { session, config, commands: cmd_rx, + load_handles: Arc::new(Mutex::new(HashMap::new())), state: PlayerState::Stopped, preload: PlayerPreload::None, @@ -377,11 +428,15 @@ impl Player { normalisation_integrator: 0.0, auto_normalise_as_album: false, + + player_id, }; // While PlayerInternal is written as a future, it still contains blocking code. // It must be run by using block_on() in a dedicated thread. - futures_executor::block_on(internal); + let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); + runtime.block_on(internal); + debug!("PlayerInternal thread finished."); }); @@ -464,6 +519,10 @@ impl Player { pub fn set_auto_normalise_as_album(&self, setting: bool) { self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting)); } + + pub fn skip_explicit_content(&self) { + self.command(PlayerCommand::SkipExplicitContent()); + } } impl Drop for Player { @@ -471,9 +530,8 @@ impl Drop for Player { debug!("Shutting down player thread ..."); self.commands = None; if let Some(handle) = self.thread_handle.take() { - match handle.join() { - Ok(_) => (), - Err(e) => error!("Player thread Error: {:?}", e), + if let Err(e) = handle.join() { + error!("Player thread Error: {:?}", e); } } } @@ -485,14 +543,15 @@ struct PlayerLoadedTrackData { stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, - stream_position_pcm: u64, + stream_position_ms: u32, + is_explicit: bool, } enum PlayerPreload { None, Loading { track_id: SpotifyId, - loader: Pin> + Send>>, + loader: Pin> + Send>>, }, Ready { track_id: SpotifyId, @@ -508,7 +567,7 @@ enum PlayerState { track_id: SpotifyId, play_request_id: u64, start_playback: bool, - loader: Pin> + Send>>, + loader: Pin> + Send>>, }, Paused { track_id: SpotifyId, @@ -519,8 +578,9 @@ enum PlayerState { stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, - stream_position_pcm: u64, + stream_position_ms: u32, suggested_to_preload_next_track: bool, + is_explicit: bool, }, Playing { track_id: SpotifyId, @@ -531,9 +591,10 @@ enum PlayerState { stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, - stream_position_pcm: u64, + stream_position_ms: u32, reported_nominal_start_time: Option, suggested_to_preload_next_track: bool, + is_explicit: bool, }, EndOfTrack { track_id: SpotifyId, @@ -550,7 +611,7 @@ impl PlayerState { Stopped | EndOfTrack { .. } | Paused { .. } | Loading { .. } => false, Playing { .. } => true, Invalid => { - error!("PlayerState is_playing: invalid state"); + error!("PlayerState::is_playing in invalid state"); exit(1); } } @@ -562,6 +623,7 @@ impl PlayerState { matches!(self, Stopped) } + #[allow(dead_code)] fn is_loading(&self) -> bool { use self::PlayerState::*; matches!(self, Loading { .. }) @@ -578,26 +640,7 @@ impl PlayerState { ref mut decoder, .. } => Some(decoder), Invalid => { - error!("PlayerState decoder: invalid state"); - exit(1); - } - } - } - - fn stream_loader_controller(&mut self) -> Option<&mut StreamLoaderController> { - use self::PlayerState::*; - match *self { - Stopped | EndOfTrack { .. } | Loading { .. } => None, - Paused { - ref mut stream_loader_controller, - .. - } - | Playing { - ref mut stream_loader_controller, - .. - } => Some(stream_loader_controller), - Invalid => { - error!("PlayerState stream_loader_controller: invalid state"); + error!("PlayerState::decoder in invalid state"); exit(1); } } @@ -605,7 +648,8 @@ impl PlayerState { fn playing_to_end_of_track(&mut self) { use self::PlayerState::*; - match mem::replace(self, Invalid) { + let new_state = mem::replace(self, Invalid); + match new_state { Playing { track_id, play_request_id, @@ -614,7 +658,8 @@ impl PlayerState { bytes_per_second, normalisation_data, stream_loader_controller, - stream_position_pcm, + stream_position_ms, + is_explicit, .. } => { *self = EndOfTrack { @@ -626,12 +671,16 @@ impl PlayerState { stream_loader_controller, bytes_per_second, duration_ms, - stream_position_pcm, + stream_position_ms, + is_explicit, }, }; } _ => { - error!("Called playing_to_end_of_track in non-playing state."); + error!( + "Called playing_to_end_of_track in non-playing state: {:?}", + new_state + ); exit(1); } } @@ -639,7 +688,8 @@ impl PlayerState { fn paused_to_playing(&mut self) { use self::PlayerState::*; - match ::std::mem::replace(self, Invalid) { + let new_state = mem::replace(self, Invalid); + match new_state { Paused { track_id, play_request_id, @@ -649,8 +699,9 @@ impl PlayerState { stream_loader_controller, duration_ms, bytes_per_second, - stream_position_pcm, + stream_position_ms, suggested_to_preload_next_track, + is_explicit, } => { *self = Playing { track_id, @@ -661,13 +712,17 @@ impl PlayerState { stream_loader_controller, duration_ms, bytes_per_second, - stream_position_pcm, + stream_position_ms, reported_nominal_start_time: None, suggested_to_preload_next_track, + is_explicit, }; } _ => { - error!("PlayerState paused_to_playing: invalid state"); + error!( + "PlayerState::paused_to_playing in invalid state: {:?}", + new_state + ); exit(1); } } @@ -675,7 +730,8 @@ impl PlayerState { fn playing_to_paused(&mut self) { use self::PlayerState::*; - match ::std::mem::replace(self, Invalid) { + let new_state = mem::replace(self, Invalid); + match new_state { Playing { track_id, play_request_id, @@ -685,9 +741,10 @@ impl PlayerState { stream_loader_controller, duration_ms, bytes_per_second, - stream_position_pcm, + stream_position_ms, reported_nominal_start_time: _, suggested_to_preload_next_track, + is_explicit, } => { *self = Paused { track_id, @@ -698,12 +755,16 @@ impl PlayerState { stream_loader_controller, duration_ms, bytes_per_second, - stream_position_pcm, + stream_position_ms, suggested_to_preload_next_track, + is_explicit, }; } _ => { - error!("PlayerState playing_to_paused: invalid state"); + error!( + "PlayerState::playing_to_paused in invalid state: {:?}", + new_state + ); exit(1); } } @@ -717,41 +778,43 @@ struct PlayerTrackLoader { impl PlayerTrackLoader { async fn find_available_alternative(&self, audio: AudioItem) -> Option { - if audio.available { + if let Err(e) = audio.availability { + error!("Track is unavailable: {}", e); + None + } else if !audio.files.is_empty() { Some(audio) } else if let Some(alternatives) = &audio.alternatives { let alternatives: FuturesUnordered<_> = alternatives .iter() - .map(|alt_id| AudioItem::get_audio_item(&self.session, *alt_id)) + .map(|alt_id| AudioItem::get_file(&self.session, *alt_id)) .collect(); alternatives .filter_map(|x| future::ready(x.ok())) - .filter(|x| future::ready(x.available)) + .filter(|x| future::ready(x.availability.is_ok())) .next() .await } else { + error!("Track should be available, but no alternatives found."); None } } - fn stream_data_rate(&self, format: FileFormat) -> usize { - match format { - FileFormat::OGG_VORBIS_96 => 12 * 1024, - FileFormat::OGG_VORBIS_160 => 20 * 1024, - FileFormat::OGG_VORBIS_320 => 40 * 1024, - FileFormat::MP3_256 => 32 * 1024, - FileFormat::MP3_320 => 40 * 1024, - FileFormat::MP3_160 => 20 * 1024, - FileFormat::MP3_96 => 12 * 1024, - FileFormat::MP3_160_ENC => 20 * 1024, - FileFormat::MP4_128_DUAL => 16 * 1024, - FileFormat::OTHER3 => 40 * 1024, // better some high guess than nothing - FileFormat::AAC_160 => 20 * 1024, - FileFormat::AAC_320 => 40 * 1024, - FileFormat::MP4_128 => 16 * 1024, - FileFormat::OTHER5 => 40 * 1024, // better some high guess than nothing - } + fn stream_data_rate(&self, format: AudioFileFormat) -> usize { + let kbps = match format { + AudioFileFormat::OGG_VORBIS_96 => 12, + AudioFileFormat::OGG_VORBIS_160 => 20, + AudioFileFormat::OGG_VORBIS_320 => 40, + AudioFileFormat::MP3_256 => 32, + AudioFileFormat::MP3_320 => 40, + AudioFileFormat::MP3_160 => 20, + AudioFileFormat::MP3_96 => 12, + AudioFileFormat::MP3_160_ENC => 20, + AudioFileFormat::AAC_24 => 3, + AudioFileFormat::AAC_48 => 6, + AudioFileFormat::FLAC_FLAC => 112, // assume 900 kbit/s on average + }; + kbps * 1024 } async fn load_track( @@ -759,7 +822,7 @@ impl PlayerTrackLoader { spotify_id: SpotifyId, position_ms: u32, ) -> Option { - let audio = match AudioItem::get_audio_item(&self.session, spotify_id).await { + let audio = match AudioItem::get_file(&self.session, spotify_id).await { Ok(audio) => match self.find_available_alternative(audio).await { Some(audio) => audio, None => { @@ -776,7 +839,21 @@ impl PlayerTrackLoader { } }; - info!("Loading <{}> with Spotify URI <{}>", audio.name, audio.uri); + info!( + "Loading <{}> with Spotify URI <{}>", + audio.name, audio.spotify_uri + ); + + let is_explicit = audio.is_explicit; + + if is_explicit { + if let Some(value) = self.session.get_user_attribute("filter-explicit-content") { + if &value == "1" { + warn!("Track is marked as explicit, which client setting forbids."); + return None; + } + } + } if audio.duration < 0 { error!( @@ -786,24 +863,37 @@ impl PlayerTrackLoader { ); return None; } + let duration_ms = audio.duration as u32; - // (Most) podcasts seem to support only 96 bit Vorbis, so fall back to it + // (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it let formats = match self.config.bitrate { Bitrate::Bitrate96 => [ - FileFormat::OGG_VORBIS_96, - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_320, + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::MP3_96, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::MP3_160, + AudioFileFormat::MP3_256, + AudioFileFormat::OGG_VORBIS_320, + AudioFileFormat::MP3_320, ], Bitrate::Bitrate160 => [ - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_96, - FileFormat::OGG_VORBIS_320, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::MP3_160, + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::MP3_96, + AudioFileFormat::MP3_256, + AudioFileFormat::OGG_VORBIS_320, + AudioFileFormat::MP3_320, ], Bitrate::Bitrate320 => [ - FileFormat::OGG_VORBIS_320, - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_96, + AudioFileFormat::OGG_VORBIS_320, + AudioFileFormat::MP3_320, + AudioFileFormat::MP3_256, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::MP3_160, + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::MP3_96, ], }; @@ -822,17 +912,11 @@ impl PlayerTrackLoader { }; let bytes_per_second = self.stream_data_rate(format); - let play_from_beginning = position_ms == 0; // This is only a loop to be able to reload the file if an error occurred // while opening a cached file. loop { - let encrypted_file = AudioFile::open( - &self.session, - file_id, - bytes_per_second, - play_from_beginning, - ); + let encrypted_file = AudioFile::open(&self.session, file_id, bytes_per_second); let encrypted_file = match encrypted_file.await { Ok(encrypted_file) => encrypted_file, @@ -841,56 +925,72 @@ impl PlayerTrackLoader { return None; } }; + let is_cached = encrypted_file.is_cached(); - let stream_loader_controller = encrypted_file.get_stream_loader_controller(); - - if play_from_beginning { - // No need to seek -> we stream from the beginning - stream_loader_controller.set_stream_mode(); - } else { - // we need to seek -> we set stream mode after the initial seek. - stream_loader_controller.set_random_access_mode(); - } + let stream_loader_controller = encrypted_file.get_stream_loader_controller().ok()?; + // Not all audio files are encrypted. If we can't get a key, try loading the track + // without decryption. If the file was encrypted after all, the decoder will fail + // parsing and bail out, so we should be safe from outputting ear-piercing noise. let key = match self.session.audio_key().request(spotify_id, file_id).await { - Ok(key) => key, + Ok(key) => Some(key), Err(e) => { - error!("Unable to load decryption key: {:?}", e); + warn!("Unable to load key, continuing without decryption: {}", e); + None + } + }; + let mut decrypted_file = AudioDecrypt::new(key, encrypted_file); + + let is_ogg_vorbis = AudioFiles::is_ogg_vorbis(format); + let (offset, mut normalisation_data) = if is_ogg_vorbis { + // Spotify stores normalisation data in a custom Ogg packet instead of Vorbis comments. + let normalisation_data = + NormalisationData::parse_from_ogg(&mut decrypted_file).ok(); + (SPOTIFY_OGG_HEADER_END, normalisation_data) + } else { + (0, None) + }; + + let audio_file = match Subfile::new( + decrypted_file, + offset, + stream_loader_controller.len() as u64, + ) { + Ok(audio_file) => audio_file, + Err(e) => { + error!("PlayerTrackLoader::load_track error opening subfile: {}", e); return None; } }; - let mut decrypted_file = AudioDecrypt::new(key, encrypted_file); - - let normalisation_data = match NormalisationData::parse_from_file(&mut decrypted_file) { - Ok(data) => data, - Err(_) => { - warn!("Unable to extract normalisation data, using default value."); - NormalisationData { - track_gain_db: 0.0, - track_peak: 1.0, - album_gain_db: 0.0, - album_peak: 1.0, + let mut symphonia_decoder = |audio_file, format| { + SymphoniaDecoder::new(audio_file, format).map(|mut decoder| { + // For formats other that Vorbis, we'll try getting normalisation data from + // ReplayGain metadata fields, if present. + if normalisation_data.is_none() { + normalisation_data = decoder.normalisation_data(); } - } + Box::new(decoder) as Decoder + }) }; - let audio_file = Subfile::new(decrypted_file, 0xa7); - - let result = if self.config.passthrough { - match PassthroughDecoder::new(audio_file) { - Ok(result) => Ok(Box::new(result) as Decoder), - Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())), - } + #[cfg(feature = "passthrough-decoder")] + let decoder_type = if self.config.passthrough { + PassthroughDecoder::new(audio_file, format).map(|x| Box::new(x) as Decoder) } else { - match VorbisDecoder::new(audio_file) { - Ok(result) => Ok(Box::new(result) as Decoder), - Err(e) => Err(DecoderError::LewtonDecoder(e.to_string())), - } + symphonia_decoder(audio_file, format) }; - let mut decoder = match result { + #[cfg(not(feature = "passthrough-decoder"))] + let decoder_type = symphonia_decoder(audio_file, format); + + let normalisation_data = normalisation_data.unwrap_or_else(|| { + warn!("Unable to get normalisation data, continuing with defaults."); + NormalisationData::default() + }); + + let mut decoder = match decoder_type { Ok(decoder) => decoder, Err(e) if is_cached => { warn!( @@ -920,15 +1020,24 @@ impl PlayerTrackLoader { } }; - let position_pcm = PlayerInternal::position_ms_to_pcm(position_ms); - - if position_pcm != 0 { - if let Err(e) = decoder.seek(position_pcm) { - error!("PlayerTrackLoader load_track: {}", e); + // Ensure the starting position. Even when we want to play from the beginning, + // the cursor may have been moved by parsing normalisation data. This may not + // matter for playback (but won't hurt either), but may be useful for the + // passthrough decoder. + let stream_position_ms = match decoder.seek(position_ms) { + Ok(new_position_ms) => new_position_ms, + Err(e) => { + error!( + "PlayerTrackLoader::load_track error seeking to starting position {}: {}", + position_ms, e + ); + return None; } - stream_loader_controller.set_stream_mode(); - } - let stream_position_pcm = position_pcm; + }; + + // Ensure streaming mode now that we are ready to play from the requested position. + stream_loader_controller.set_stream_mode(); + info!("<{}> ({} ms) loaded", audio.name, audio.duration); return Some(PlayerLoadedTrackData { @@ -937,7 +1046,8 @@ impl PlayerTrackLoader { stream_loader_controller, bytes_per_second, duration_ms, - stream_position_pcm, + stream_position_ms, + is_explicit, }); } } @@ -965,7 +1075,9 @@ impl Future for PlayerInternal { }; if let Some(cmd) = cmd { - self.handle_command(cmd); + if let Err(e) = self.handle_command(cmd) { + error!("Error handling command: {}", e); + } } // Handle loading of a new track to play @@ -976,31 +1088,34 @@ impl Future for PlayerInternal { play_request_id, } = self.state { - match loader.as_mut().poll(cx) { - Poll::Ready(Ok(loaded_track)) => { - self.start_playback( - track_id, - play_request_id, - loaded_track, - start_playback, - ); - if let PlayerState::Loading { .. } = self.state { - error!("The state wasn't changed by start_playback()"); - exit(1); + // The loader may be terminated if we are trying to load the same track + // as before, and that track failed to open before. + if !loader.as_mut().is_terminated() { + match loader.as_mut().poll(cx) { + Poll::Ready(Ok(loaded_track)) => { + self.start_playback( + track_id, + play_request_id, + loaded_track, + start_playback, + ); + if let PlayerState::Loading { .. } = self.state { + error!("The state wasn't changed by start_playback()"); + exit(1); + } } + Poll::Ready(Err(e)) => { + error!( + "Skipping to next track, unable to load track <{:?}>: {:?}", + track_id, e + ); + self.send_event(PlayerEvent::Unavailable { + track_id, + play_request_id, + }) + } + Poll::Pending => (), } - Poll::Ready(Err(e)) => { - warn!( - "Skipping to next track, unable to load track <{:?}>: {:?}", - track_id, e - ); - debug_assert!(self.state.is_loading()); - self.send_event(PlayerEvent::EndOfTrack { - track_id, - play_request_id, - }) - } - Poll::Pending => (), } } @@ -1047,54 +1162,80 @@ impl Future for PlayerInternal { play_request_id, ref mut decoder, normalisation_factor, - ref mut stream_position_pcm, + ref mut stream_position_ms, ref mut reported_nominal_start_time, duration_ms, .. } = self.state { match decoder.next_packet() { - Ok(packet) => { - if !passthrough { - if let Some(ref packet) = packet { - match packet.samples() { - Ok(samples) => { - *stream_position_pcm += - (samples.len() / NUM_CHANNELS as usize) as u64; - let stream_position_millis = - Self::position_pcm_to_ms(*stream_position_pcm); + Ok(result) => { + if let Some((ref packet_position, ref packet)) = result { + let new_stream_position_ms = packet_position.position_ms; + let expected_position_ms = std::mem::replace( + &mut *stream_position_ms, + new_stream_position_ms, + ); + if !passthrough { + match packet.samples() { + Ok(_) => { + let new_stream_position = Duration::from_millis( + new_stream_position_ms as u64, + ); + + let now = Instant::now(); + + // Only notify if we're skipped some packets *or* we are behind. + // If we're ahead it's probably due to a buffer of the backend + // and we're actually in time. let notify_about_position = match *reported_nominal_start_time { None => true, Some(reported_nominal_start_time) => { - // only notify if we're behind. If we're ahead it's probably due to a buffer of the backend and we're actually in time. - let lag = (Instant::now() - - reported_nominal_start_time) - .as_millis() - as i64 - - stream_position_millis as i64; - lag > Duration::from_secs(1).as_millis() - as i64 + let mut notify = false; + + if packet_position.skipped { + if let Some(ahead) = new_stream_position + .checked_sub(Duration::from_millis( + expected_position_ms as u64, + )) + { + notify |= + ahead >= Duration::from_secs(1) + } + } + + if let Some(lag) = now + .checked_duration_since( + reported_nominal_start_time, + ) + { + if let Some(lag) = + lag.checked_sub(new_stream_position) + { + notify |= + lag >= Duration::from_secs(1) + } + } + + notify } }; + if notify_about_position { - *reported_nominal_start_time = Some( - Instant::now() - - Duration::from_millis( - stream_position_millis as u64, - ), - ); + *reported_nominal_start_time = + now.checked_sub(new_stream_position); self.send_event(PlayerEvent::Playing { track_id, play_request_id, - position_ms: stream_position_millis as u32, + position_ms: new_stream_position_ms as u32, duration_ms, }); } } Err(e) => { - warn!("Skipping to next track, unable to decode samples for track <{:?}>: {:?}", track_id, e); + error!("Skipping to next track, unable to decode samples for track <{:?}>: {:?}", track_id, e); self.send_event(PlayerEvent::EndOfTrack { track_id, play_request_id, @@ -1102,15 +1243,12 @@ impl Future for PlayerInternal { } } } - } else { - // position, even if irrelevant, must be set so that seek() is called - *stream_position_pcm = duration_ms.into(); } - self.handle_packet(packet, normalisation_factor); + self.handle_packet(result, normalisation_factor); } Err(e) => { - warn!("Skipping to next track, unable to get next packet for track <{:?}>: {:?}", track_id, e); + error!("Skipping to next track, unable to get next packet for track <{:?}>: {:?}", track_id, e); self.send_event(PlayerEvent::EndOfTrack { track_id, play_request_id, @@ -1127,7 +1265,7 @@ impl Future for PlayerInternal { track_id, play_request_id, duration_ms, - stream_position_pcm, + stream_position_ms, ref mut stream_loader_controller, ref mut suggested_to_preload_next_track, .. @@ -1136,14 +1274,14 @@ impl Future for PlayerInternal { track_id, play_request_id, duration_ms, - stream_position_pcm, + stream_position_ms, ref mut stream_loader_controller, ref mut suggested_to_preload_next_track, .. } = self.state { if (!*suggested_to_preload_next_track) - && ((duration_ms as i64 - Self::position_pcm_to_ms(stream_position_pcm) as i64) + && ((duration_ms as i64 - stream_position_ms as i64) < PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS as i64) && stream_loader_controller.range_to_end_available() { @@ -1167,14 +1305,6 @@ impl Future for PlayerInternal { } impl PlayerInternal { - fn position_pcm_to_ms(position_pcm: u64) -> u32 { - (position_pcm as f64 * MS_PER_PAGE) as u32 - } - - fn position_ms_to_pcm(position_ms: u32) -> u64 { - (position_ms as f64 * PAGES_PER_MS) as u64 - } - fn ensure_sink_running(&mut self) { if self.sink_status != SinkStatus::Running { trace!("== Starting sink =="); @@ -1255,7 +1385,7 @@ impl PlayerInternal { } PlayerState::Stopped => (), PlayerState::Invalid => { - error!("PlayerInternal handle_player_stop: invalid state"); + error!("PlayerInternal::handle_player_stop in invalid state"); exit(1); } } @@ -1265,23 +1395,21 @@ impl PlayerInternal { if let PlayerState::Paused { track_id, play_request_id, - stream_position_pcm, + stream_position_ms, duration_ms, .. } = self.state { self.state.paused_to_playing(); - - let position_ms = Self::position_pcm_to_ms(stream_position_pcm); self.send_event(PlayerEvent::Playing { track_id, play_request_id, - position_ms, + position_ms: stream_position_ms, duration_ms, }); self.ensure_sink_running(); } else { - warn!("Player::play called from invalid state"); + error!("Player::play called from invalid state: {:?}", self.state); } } @@ -1289,7 +1417,7 @@ impl PlayerInternal { if let PlayerState::Playing { track_id, play_request_id, - stream_position_pcm, + stream_position_ms, duration_ms, .. } = self.state @@ -1297,21 +1425,24 @@ impl PlayerInternal { self.state.playing_to_paused(); self.ensure_sink_stopped(false); - let position_ms = Self::position_pcm_to_ms(stream_position_pcm); self.send_event(PlayerEvent::Paused { track_id, play_request_id, - position_ms, + position_ms: stream_position_ms, duration_ms, }); } else { - warn!("Player::pause called from invalid state"); + error!("Player::pause called from invalid state: {:?}", self.state); } } - fn handle_packet(&mut self, packet: Option, normalisation_factor: f64) { + fn handle_packet( + &mut self, + packet: Option<(AudioPacketPosition, AudioPacket)>, + normalisation_factor: f64, + ) { match packet { - Some(mut packet) => { + Some((_, mut packet)) => { if !packet.is_empty() { if let AudioPacket::Samples(ref mut data) = packet { // Get the volume for the packet. @@ -1322,6 +1453,7 @@ impl PlayerInternal { // For the basic normalisation method, a normalisation factor of 1.0 indicates that // there is nothing to normalise (all samples should pass unaltered). For the // dynamic method, there may still be peaks that we want to shave off. + // No matter the case we apply volume attenuation last if there is any. if !self.config.normalisation && volume < 1.0 { for sample in data.iter_mut() { @@ -1455,7 +1587,7 @@ impl PlayerInternal { loaded_track: PlayerLoadedTrackData, start_playback: bool, ) { - let position_ms = Self::position_pcm_to_ms(loaded_track.stream_position_pcm); + let position_ms = loaded_track.stream_position_ms; let mut config = self.config.clone(); if config.normalisation_type == NormalisationType::Auto { @@ -1487,11 +1619,11 @@ impl PlayerInternal { stream_loader_controller: loaded_track.stream_loader_controller, duration_ms: loaded_track.duration_ms, bytes_per_second: loaded_track.bytes_per_second, - stream_position_pcm: loaded_track.stream_position_pcm, - reported_nominal_start_time: Some( - Instant::now() - Duration::from_millis(position_ms as u64), - ), + stream_position_ms: loaded_track.stream_position_ms, + reported_nominal_start_time: Instant::now() + .checked_sub(Duration::from_millis(position_ms as u64)), suggested_to_preload_next_track: false, + is_explicit: loaded_track.is_explicit, }; } else { self.ensure_sink_stopped(false); @@ -1505,8 +1637,9 @@ impl PlayerInternal { stream_loader_controller: loaded_track.stream_loader_controller, duration_ms: loaded_track.duration_ms, bytes_per_second: loaded_track.bytes_per_second, - stream_position_pcm: loaded_track.stream_position_pcm, + stream_position_ms: loaded_track.stream_position_ms, suggested_to_preload_next_track: false, + is_explicit: loaded_track.is_explicit, }; self.send_event(PlayerEvent::Paused { @@ -1524,7 +1657,7 @@ impl PlayerInternal { play_request_id: u64, play: bool, position_ms: u32, - ) { + ) -> PlayerResult { if !self.config.gapless { self.ensure_sink_stopped(play); } @@ -1555,8 +1688,10 @@ impl PlayerInternal { position_ms, }), PlayerState::Invalid { .. } => { - error!("PlayerInternal handle_command_load: invalid state"); - exit(1); + return Err(Error::internal(format!( + "Player::handle_command_load called from invalid state: {:?}", + self.state + ))); } } @@ -1574,62 +1709,42 @@ impl PlayerInternal { let mut loaded_track = match mem::replace(&mut self.state, PlayerState::Invalid) { PlayerState::EndOfTrack { loaded_track, .. } => loaded_track, _ => { - error!("PlayerInternal handle_command_load: Invalid PlayerState"); - exit(1); + return Err(Error::internal(format!("PlayerInternal::handle_command_load repeating the same track: invalid state: {:?}", self.state))); } }; - let position_pcm = Self::position_ms_to_pcm(position_ms); - - if position_pcm != loaded_track.stream_position_pcm { - loaded_track - .stream_loader_controller - .set_random_access_mode(); - if let Err(e) = loaded_track.decoder.seek(position_pcm) { - // This may be blocking. - error!("PlayerInternal handle_command_load: {}", e); - } - loaded_track.stream_loader_controller.set_stream_mode(); - loaded_track.stream_position_pcm = position_pcm; + if position_ms != loaded_track.stream_position_ms { + // This may be blocking. + loaded_track.stream_position_ms = loaded_track.decoder.seek(position_ms)?; } self.preload = PlayerPreload::None; self.start_playback(track_id, play_request_id, loaded_track, play); if let PlayerState::Invalid = self.state { - error!("start_playback() hasn't set a valid player state."); - exit(1); + return Err(Error::internal(format!("PlayerInternal::handle_command_load repeating the same track: start_playback() did not transition to valid player state: {:?}", self.state))); } - return; + return Ok(()); } } // Check if we are already playing the track. If so, just do a seek and update our info. if let PlayerState::Playing { track_id: current_track_id, - ref mut stream_position_pcm, + ref mut stream_position_ms, ref mut decoder, - ref mut stream_loader_controller, .. } | PlayerState::Paused { track_id: current_track_id, - ref mut stream_position_pcm, + ref mut stream_position_ms, ref mut decoder, - ref mut stream_loader_controller, .. } = self.state { if current_track_id == track_id { // we can use the current decoder. Ensure it's at the correct position. - let position_pcm = Self::position_ms_to_pcm(position_ms); - - if position_pcm != *stream_position_pcm { - stream_loader_controller.set_random_access_mode(); - if let Err(e) = decoder.seek(position_pcm) { - // This may be blocking. - error!("PlayerInternal handle_command_load: {}", e); - } - stream_loader_controller.set_stream_mode(); - *stream_position_pcm = position_pcm; + if position_ms != *stream_position_ms { + // This may be blocking. + *stream_position_ms = decoder.seek(position_ms)?; } // Move the info from the current state into a PlayerLoadedTrackData so we can use @@ -1637,21 +1752,23 @@ impl PlayerInternal { let old_state = mem::replace(&mut self.state, PlayerState::Invalid); if let PlayerState::Playing { - stream_position_pcm, + stream_position_ms, decoder, stream_loader_controller, bytes_per_second, duration_ms, normalisation_data, + is_explicit, .. } | PlayerState::Paused { - stream_position_pcm, + stream_position_ms, decoder, stream_loader_controller, bytes_per_second, duration_ms, normalisation_data, + is_explicit, .. } = old_state { @@ -1661,21 +1778,20 @@ impl PlayerInternal { stream_loader_controller, bytes_per_second, duration_ms, - stream_position_pcm, + stream_position_ms, + is_explicit, }; self.preload = PlayerPreload::None; self.start_playback(track_id, play_request_id, loaded_track, play); if let PlayerState::Invalid = self.state { - error!("start_playback() hasn't set a valid player state."); - exit(1); + return Err(Error::internal(format!("PlayerInternal::handle_command_load already playing this track: start_playback() did not transition to valid player state: {:?}", self.state))); } - return; + return Ok(()); } else { - error!("PlayerInternal handle_command_load: Invalid PlayerState"); - exit(1); + return Err(Error::internal(format!("PlayerInternal::handle_command_load already playing this track: invalid state: {:?}", self.state))); } } } @@ -1693,23 +1809,14 @@ impl PlayerInternal { mut loaded_track, } = preload { - let position_pcm = Self::position_ms_to_pcm(position_ms); - - if position_pcm != loaded_track.stream_position_pcm { - loaded_track - .stream_loader_controller - .set_random_access_mode(); - if let Err(e) = loaded_track.decoder.seek(position_pcm) { - // This may be blocking - error!("PlayerInternal handle_command_load: {}", e); - } - loaded_track.stream_loader_controller.set_stream_mode(); + if position_ms != loaded_track.stream_position_ms { + // This may be blocking + loaded_track.stream_position_ms = loaded_track.decoder.seek(position_ms)?; } self.start_playback(track_id, play_request_id, *loaded_track, play); - return; + return Ok(()); } else { - error!("PlayerInternal handle_command_load: Invalid PlayerState"); - exit(1); + return Err(Error::internal(format!("PlayerInternal::handle_command_loading preloaded track: invalid state: {:?}", self.state))); } } } @@ -1757,6 +1864,8 @@ impl PlayerInternal { start_playback: play, loader, }; + + Ok(()) } fn handle_command_preload(&mut self, track_id: SpotifyId) { @@ -1810,40 +1919,30 @@ impl PlayerInternal { } } - fn handle_command_seek(&mut self, position_ms: u32) { - if let Some(stream_loader_controller) = self.state.stream_loader_controller() { - stream_loader_controller.set_random_access_mode(); - } + fn handle_command_seek(&mut self, position_ms: u32) -> PlayerResult { if let Some(decoder) = self.state.decoder() { - let position_pcm = Self::position_ms_to_pcm(position_ms); - - match decoder.seek(position_pcm) { - Ok(_) => { + match decoder.seek(position_ms) { + Ok(new_position_ms) => { if let PlayerState::Playing { - ref mut stream_position_pcm, + ref mut stream_position_ms, .. } | PlayerState::Paused { - ref mut stream_position_pcm, + ref mut stream_position_ms, .. } = self.state { - *stream_position_pcm = position_pcm; + *stream_position_ms = new_position_ms; } } - Err(e) => error!("PlayerInternal handle_command_seek: {}", e), + Err(e) => error!("PlayerInternal::handle_command_seek error: {}", e), } } else { - warn!("Player::seek called from invalid state"); - } - - // If we're playing, ensure, that we have enough data leaded to avoid a buffer underrun. - if let Some(stream_loader_controller) = self.state.stream_loader_controller() { - stream_loader_controller.set_stream_mode(); + error!("Player::seek called from invalid state: {:?}", self.state); } // ensure we have a bit of a buffer of downloaded data - self.preload_data_before_playback(); + self.preload_data_before_playback()?; if let PlayerState::Playing { track_id, @@ -1854,7 +1953,7 @@ impl PlayerInternal { } = self.state { *reported_nominal_start_time = - Some(Instant::now() - Duration::from_millis(position_ms as u64)); + Instant::now().checked_sub(Duration::from_millis(position_ms as u64)); self.send_event(PlayerEvent::Playing { track_id, play_request_id, @@ -1876,9 +1975,11 @@ impl PlayerInternal { duration_ms, }); } + + Ok(()) } - fn handle_command(&mut self, cmd: PlayerCommand) { + fn handle_command(&mut self, cmd: PlayerCommand) -> PlayerResult { debug!("command={:?}", cmd); match cmd { PlayerCommand::Load { @@ -1886,11 +1987,11 @@ impl PlayerInternal { play_request_id, play, position_ms, - } => self.handle_command_load(track_id, play_request_id, play, position_ms), + } => self.handle_command_load(track_id, play_request_id, play, position_ms)?, PlayerCommand::Preload { track_id } => self.handle_command_preload(track_id), - PlayerCommand::Seek(position_ms) => self.handle_command_seek(position_ms), + PlayerCommand::Seek(position_ms) => self.handle_command_seek(position_ms)?, PlayerCommand::Play => self.handle_play(), @@ -1909,7 +2010,33 @@ impl PlayerInternal { PlayerCommand::SetAutoNormaliseAsAlbum(setting) => { self.auto_normalise_as_album = setting } - } + + PlayerCommand::SkipExplicitContent() => { + if let PlayerState::Playing { + track_id, + play_request_id, + is_explicit, + .. + } + | PlayerState::Paused { + track_id, + play_request_id, + is_explicit, + .. + } = self.state + { + if is_explicit { + warn!("Currently loaded track is explicit, which client setting forbids -- skipping to next track."); + self.send_event(PlayerEvent::EndOfTrack { + track_id, + play_request_id, + }) + } + } + } + }; + + Ok(()) } fn send_event(&mut self, event: PlayerEvent) { @@ -1918,10 +2045,10 @@ impl PlayerInternal { } fn load_track( - &self, + &mut self, spotify_id: SpotifyId, position_ms: u32, - ) -> impl Future> + Send + 'static { + ) -> impl FusedFuture> + Send + 'static { // This method creates a future that returns the loaded stream and associated info. // Ideally all work should be done using asynchronous code. However, seek() on the // audio stream is implemented in a blocking fashion. Thus, we can't turn it into future @@ -1935,52 +2062,77 @@ impl PlayerInternal { let (result_tx, result_rx) = oneshot::channel(); - std::thread::spawn(move || { - let data = futures_executor::block_on(loader.load_track(spotify_id, position_ms)); + let load_handles_clone = self.load_handles.clone(); + let handle = tokio::runtime::Handle::current(); + let load_handle = thread::spawn(move || { + let data = handle.block_on(loader.load_track(spotify_id, position_ms)); if let Some(data) = data { let _ = result_tx.send(data); } + + let mut load_handles = load_handles_clone.lock(); + load_handles.remove(&thread::current().id()); }); + let mut load_handles = self.load_handles.lock(); + load_handles.insert(load_handle.thread().id(), load_handle); + result_rx.map_err(|_| ()) } - fn preload_data_before_playback(&mut self) { + fn preload_data_before_playback(&mut self) -> PlayerResult { if let PlayerState::Playing { bytes_per_second, ref mut stream_loader_controller, .. } = self.state { + let ping_time = stream_loader_controller.ping_time().as_secs_f32(); + // Request our read ahead range let request_data_length = max( - (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS - * stream_loader_controller.ping_time().as_secs_f32() - * bytes_per_second as f32) as usize, + (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS * ping_time * bytes_per_second as f32) + as usize, (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, ); - stream_loader_controller.fetch_next(request_data_length); - // Request the part we want to wait for blocking. This effecively means we wait for the previous request to partially complete. + // Request the part we want to wait for blocking. This effectively means we wait for the previous request to partially complete. let wait_for_data_length = max( - (READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS - * stream_loader_controller.ping_time().as_secs_f32() - * bytes_per_second as f32) as usize, + (READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS * ping_time * bytes_per_second as f32) + as usize, (READ_AHEAD_BEFORE_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, ); - stream_loader_controller.fetch_next_blocking(wait_for_data_length); + stream_loader_controller + .fetch_next_and_wait(request_data_length, wait_for_data_length) + .map_err(Into::into) + } else { + Ok(()) } } } impl Drop for PlayerInternal { fn drop(&mut self) { - debug!("drop PlayerInternal[{}]", self.session.session_id()); + debug!("drop PlayerInternal[{}]", self.player_id); + + let handles: Vec> = { + // waiting for the thread while holding the mutex would result in a deadlock + let mut load_handles = self.load_handles.lock(); + + load_handles + .drain() + .map(|(_thread_id, handle)| handle) + .collect() + }; + + for handle in handles { + let _ = handle.join(); + } } } -impl ::std::fmt::Debug for PlayerCommand { - fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { +impl fmt::Debug for PlayerCommand { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { PlayerCommand::Load { track_id, @@ -2011,12 +2163,13 @@ impl ::std::fmt::Debug for PlayerCommand { .debug_tuple("SetAutoNormaliseAsAlbum") .field(&setting) .finish(), + PlayerCommand::SkipExplicitContent() => f.debug_tuple("SkipExplicitContent").finish(), } } } -impl ::std::fmt::Debug for PlayerState { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { +impl fmt::Debug for PlayerState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use PlayerState::*; match *self { Stopped => f.debug_struct("Stopped").finish(), @@ -2060,17 +2213,23 @@ impl ::std::fmt::Debug for PlayerState { } } } + struct Subfile { stream: T, offset: u64, + length: u64, } impl Subfile { - pub fn new(mut stream: T, offset: u64) -> Subfile { - if let Err(e) = stream.seek(SeekFrom::Start(offset)) { - error!("Subfile new Error: {}", e); - } - Subfile { stream, offset } + pub fn new(mut stream: T, offset: u64, length: u64) -> Result, io::Error> { + let target = SeekFrom::Start(offset); + stream.seek(target)?; + + Ok(Subfile { + stream, + offset, + length, + }) } } @@ -2081,14 +2240,35 @@ impl Read for Subfile { } impl Seek for Subfile { - fn seek(&mut self, mut pos: SeekFrom) -> io::Result { - pos = match pos { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let pos = match pos { SeekFrom::Start(offset) => SeekFrom::Start(offset + self.offset), - x => x, + SeekFrom::End(offset) => { + if (self.length as i64 - offset) < self.offset as i64 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "newpos would be < self.offset", + )); + } + pos + } + _ => pos, }; let newpos = self.stream.seek(pos)?; - - Ok(newpos.saturating_sub(self.offset)) + Ok(newpos - self.offset) + } +} + +impl MediaSource for Subfile +where + R: Read + Seek + Send + Sync, +{ + fn is_seekable(&self) -> bool { + true + } + + fn byte_len(&self) -> Option { + Some(self.length) } } diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index 7a5da342..da6544a7 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -9,8 +9,8 @@ repository = "https://github.com/librespot-org/librespot" edition = "2018" [dependencies] -protobuf = "2.14.0" +protobuf = "2" [build-dependencies] -protobuf-codegen-pure = "2.14.0" -glob = "0.3.0" +glob = "0.3" +protobuf-codegen-pure = "2" diff --git a/protocol/build.rs b/protocol/build.rs index c65c109a..b7c3f44d 100644 --- a/protocol/build.rs +++ b/protocol/build.rs @@ -16,15 +16,27 @@ fn compile() { let proto_dir = Path::new(&env::var("CARGO_MANIFEST_DIR").expect("env")).join("proto"); let files = &[ + proto_dir.join("connect.proto"), + proto_dir.join("connectivity.proto"), + proto_dir.join("devices.proto"), + proto_dir.join("entity_extension_data.proto"), + proto_dir.join("extended_metadata.proto"), + proto_dir.join("extension_kind.proto"), + proto_dir.join("metadata.proto"), + proto_dir.join("player.proto"), + proto_dir.join("playlist_annotate3.proto"), + proto_dir.join("playlist_permission.proto"), + proto_dir.join("playlist4_external.proto"), + proto_dir.join("spotify/clienttoken/v0/clienttoken_http.proto"), + proto_dir.join("storage-resolve.proto"), + proto_dir.join("user_attributes.proto"), + // TODO: remove these legacy protobufs when we are on the new API completely proto_dir.join("authentication.proto"), + proto_dir.join("canvaz.proto"), + proto_dir.join("canvaz-meta.proto"), + proto_dir.join("explicit_content_pubsub.proto"), proto_dir.join("keyexchange.proto"), proto_dir.join("mercury.proto"), - proto_dir.join("metadata.proto"), - proto_dir.join("playlist4changes.proto"), - proto_dir.join("playlist4content.proto"), - proto_dir.join("playlist4issues.proto"), - proto_dir.join("playlist4meta.proto"), - proto_dir.join("playlist4ops.proto"), proto_dir.join("pubsub.proto"), proto_dir.join("spirc.proto"), ]; diff --git a/protocol/proto/AdContext.proto b/protocol/proto/AdContext.proto new file mode 100644 index 00000000..ba56bd00 --- /dev/null +++ b/protocol/proto/AdContext.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdContext { + optional string preceding_content_uri = 1; + optional string preceding_playback_id = 2; + optional int32 preceding_end_position = 3; + repeated string ad_ids = 4; + optional string ad_request_id = 5; + optional string succeeding_content_uri = 6; + optional string succeeding_playback_id = 7; + optional int32 succeeding_start_position = 8; + optional int32 preceding_duration = 9; +} diff --git a/protocol/proto/AdDecisionEvent.proto b/protocol/proto/AdDecisionEvent.proto new file mode 100644 index 00000000..07a0a940 --- /dev/null +++ b/protocol/proto/AdDecisionEvent.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdDecisionEvent { + optional string request_id = 1; + optional string decision_request_id = 2; + optional string decision_type = 3; +} diff --git a/protocol/proto/AdError.proto b/protocol/proto/AdError.proto new file mode 100644 index 00000000..1a69e788 --- /dev/null +++ b/protocol/proto/AdError.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdError { + optional string request_type = 1; + optional string error_message = 2; + optional int64 http_error_code = 3; + optional string request_url = 4; + optional string tracking_event = 5; +} diff --git a/protocol/proto/AdEvent.proto b/protocol/proto/AdEvent.proto new file mode 100644 index 00000000..69cf82bb --- /dev/null +++ b/protocol/proto/AdEvent.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdEvent { + optional string request_id = 1; + optional string app_startup_id = 2; + optional string ad_id = 3; + optional string lineitem_id = 4; + optional string creative_id = 5; + optional string slot = 6; + optional string format = 7; + optional string type = 8; + optional bool skippable = 9; + optional string event = 10; + optional string event_source = 11; + optional string event_reason = 12; + optional int32 event_sequence_num = 13; + optional int32 position = 14; + optional int32 duration = 15; + optional bool in_focus = 16; + optional float volume = 17; + optional string product_name = 18; +} diff --git a/protocol/proto/AdRequestEvent.proto b/protocol/proto/AdRequestEvent.proto new file mode 100644 index 00000000..3ffdf863 --- /dev/null +++ b/protocol/proto/AdRequestEvent.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdRequestEvent { + optional string feature_identifier = 1; + optional string requested_ad_type = 2; + optional int64 latency_ms = 3; + repeated string requested_ad_types = 4; +} diff --git a/protocol/proto/AdSlotEvent.proto b/protocol/proto/AdSlotEvent.proto new file mode 100644 index 00000000..1f345b69 --- /dev/null +++ b/protocol/proto/AdSlotEvent.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdSlotEvent { + optional string event = 1; + optional string ad_id = 2; + optional string lineitem_id = 3; + optional string creative_id = 4; + optional string slot = 5; + optional string format = 6; + optional bool in_focus = 7; + optional string app_startup_id = 8; + optional string request_id = 9; +} diff --git a/protocol/proto/AmazonWakeUpTime.proto b/protocol/proto/AmazonWakeUpTime.proto new file mode 100644 index 00000000..25d64c48 --- /dev/null +++ b/protocol/proto/AmazonWakeUpTime.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AmazonWakeUpTime { + optional int64 delay_to_online = 1; +} diff --git a/protocol/proto/AudioDriverError.proto b/protocol/proto/AudioDriverError.proto new file mode 100644 index 00000000..3c97b461 --- /dev/null +++ b/protocol/proto/AudioDriverError.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioDriverError { + optional int64 error_code = 1; + optional string location = 2; + optional string driver_name = 3; + optional string additional_data = 4; +} diff --git a/protocol/proto/AudioDriverInfo.proto b/protocol/proto/AudioDriverInfo.proto new file mode 100644 index 00000000..23bae0a7 --- /dev/null +++ b/protocol/proto/AudioDriverInfo.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioDriverInfo { + optional string driver_name = 1; + optional string output_device_name = 2; + optional string output_device_category = 3; + optional string reason = 4; +} diff --git a/protocol/proto/AudioFileSelection.proto b/protocol/proto/AudioFileSelection.proto new file mode 100644 index 00000000..d99b36f4 --- /dev/null +++ b/protocol/proto/AudioFileSelection.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioFileSelection { + optional bytes playback_id = 1; + optional string strategy_name = 2; + optional int64 bitrate = 3; + optional bytes predict_id = 4; + optional string file_origin = 5; + optional int32 target_bitrate = 6; +} diff --git a/protocol/proto/AudioOffliningSettingsReport.proto b/protocol/proto/AudioOffliningSettingsReport.proto new file mode 100644 index 00000000..71d87f17 --- /dev/null +++ b/protocol/proto/AudioOffliningSettingsReport.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioOffliningSettingsReport { + optional string default_sync_bitrate_product_state = 1; + optional int64 user_selected_sync_bitrate = 2; + optional int64 sync_bitrate = 3; + optional bool sync_over_cellular = 4; + optional string primary_resource_type = 5; +} diff --git a/protocol/proto/AudioRateLimit.proto b/protocol/proto/AudioRateLimit.proto new file mode 100644 index 00000000..0ead830d --- /dev/null +++ b/protocol/proto/AudioRateLimit.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioRateLimit { + optional string driver_name = 1; + optional string output_device_name = 2; + optional string output_device_category = 3; + optional int64 max_size = 4; + optional int64 refill_per_milliseconds = 5; + optional int64 frames_requested = 6; + optional int64 frames_acquired = 7; + optional bytes playback_id = 8; +} diff --git a/protocol/proto/AudioSessionEvent.proto b/protocol/proto/AudioSessionEvent.proto new file mode 100644 index 00000000..c9b1a531 --- /dev/null +++ b/protocol/proto/AudioSessionEvent.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioSessionEvent { + optional string event = 1; + optional string context = 2; + optional string json_data = 3; +} diff --git a/protocol/proto/AudioSettingsReport.proto b/protocol/proto/AudioSettingsReport.proto new file mode 100644 index 00000000..e99ea8ec --- /dev/null +++ b/protocol/proto/AudioSettingsReport.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioSettingsReport { + optional bool offline_mode = 1; + optional string default_play_bitrate_product_state = 2; + optional int64 user_selected_bitrate = 3; + optional int64 play_bitrate = 4; + optional bool low_bitrate_on_cellular = 5; + optional string default_sync_bitrate_product_state = 6; + optional int64 user_selected_sync_bitrate = 7; + optional int64 sync_bitrate = 8; + optional bool sync_over_cellular = 9; + optional string enable_gapless_product_state = 10; + optional bool enable_gapless = 11; + optional string enable_crossfade_product_state = 12; + optional bool enable_crossfade = 13; + optional int64 crossfade_time = 14; + optional bool enable_normalization = 15; + optional int64 playback_speed = 16; + optional string audio_loudness_level = 17; + optional bool enable_automix = 18; + optional bool enable_silence_trimmer = 19; + optional bool enable_mono_downmixer = 20; +} diff --git a/protocol/proto/AudioStreamingSettingsReport.proto b/protocol/proto/AudioStreamingSettingsReport.proto new file mode 100644 index 00000000..ef6e4730 --- /dev/null +++ b/protocol/proto/AudioStreamingSettingsReport.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioStreamingSettingsReport { + optional string default_play_bitrate_product_state = 1; + optional int64 user_selected_play_bitrate_cellular = 2; + optional int64 user_selected_play_bitrate_wifi = 3; + optional int64 play_bitrate_cellular = 4; + optional int64 play_bitrate_wifi = 5; + optional bool allow_downgrade = 6; +} diff --git a/protocol/proto/BoomboxPlaybackInstrumentation.proto b/protocol/proto/BoomboxPlaybackInstrumentation.proto new file mode 100644 index 00000000..01e3f2c7 --- /dev/null +++ b/protocol/proto/BoomboxPlaybackInstrumentation.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message BoomboxPlaybackInstrumentation { + optional bytes playback_id = 1; + optional bool was_playback_paused = 2; + repeated string dimensions = 3; + map total_buffer_size = 4; + map number_of_calls = 5; + map total_duration = 6; + map first_call_time = 7; + map last_call_time = 8; +} diff --git a/protocol/proto/BrokenObject.proto b/protocol/proto/BrokenObject.proto new file mode 100644 index 00000000..3bdb6677 --- /dev/null +++ b/protocol/proto/BrokenObject.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message BrokenObject { + optional string type = 1; + optional string id = 2; + optional int64 error_code = 3; + optional bytes playback_id = 4; +} diff --git a/protocol/proto/CacheError.proto b/protocol/proto/CacheError.proto new file mode 100644 index 00000000..ad85c342 --- /dev/null +++ b/protocol/proto/CacheError.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CacheError { + optional int64 error_code = 1; + optional int64 os_error_code = 2; + optional string realm = 3; + optional bytes file_id = 4; + optional int64 num_errors = 5; + optional string cache_path = 6; + optional int64 size = 7; + optional int64 range_start = 8; + optional int64 range_end = 9; +} diff --git a/protocol/proto/CachePruningReport.proto b/protocol/proto/CachePruningReport.proto new file mode 100644 index 00000000..3225f1d5 --- /dev/null +++ b/protocol/proto/CachePruningReport.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CachePruningReport { + optional bytes cache_id = 1; + optional int64 time_spent_pruning_ms = 2; + optional int64 size_before_prune_kb = 3; + optional int64 size_after_prune_kb = 4; + optional int64 num_entries_pruned = 5; + optional int64 num_entries_pruned_expired = 6; + optional int64 size_entries_pruned_expired_kb = 7; + optional int64 num_entries_pruned_limit = 8; + optional int64 size_pruned_limit_kb = 9; + optional int64 num_entries_pruned_never_used = 10; + optional int64 size_pruned_never_used_kb = 11; + optional int64 num_entries_pruned_max_realm_size = 12; + optional int64 size_pruned_max_realm_size_kb = 13; + optional int64 num_entries_pruned_min_free_space = 14; + optional int64 size_pruned_min_free_space_kb = 15; +} diff --git a/protocol/proto/CacheRealmPruningReport.proto b/protocol/proto/CacheRealmPruningReport.proto new file mode 100644 index 00000000..479a26a5 --- /dev/null +++ b/protocol/proto/CacheRealmPruningReport.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CacheRealmPruningReport { + optional bytes cache_id = 1; + optional int64 realm_id = 2; + optional int64 num_entries_pruned = 3; + optional int64 num_entries_pruned_expired = 4; + optional int64 size_entries_pruned_expired_kb = 5; + optional int64 num_entries_pruned_limit = 6; + optional int64 size_pruned_limit_kb = 7; + optional int64 num_entries_pruned_never_used = 8; + optional int64 size_pruned_never_used_kb = 9; + optional int64 num_entries_pruned_max_realm_size = 10; + optional int64 size_pruned_max_realm_size_kb = 11; + optional int64 num_entries_pruned_min_free_space = 12; + optional int64 size_pruned_min_free_space_kb = 13; +} diff --git a/protocol/proto/CacheRealmReport.proto b/protocol/proto/CacheRealmReport.proto new file mode 100644 index 00000000..4d3c8a55 --- /dev/null +++ b/protocol/proto/CacheRealmReport.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CacheRealmReport { + optional bytes cache_id = 1; + optional int64 realm_id = 2; + optional int64 num_entries = 3; + optional int64 num_locked_entries = 4; + optional int64 num_locked_entries_current_user = 5; + optional int64 num_full_entries = 6; + optional int64 size_kb = 7; + optional int64 locked_size_kb = 8; +} diff --git a/protocol/proto/CacheReport.proto b/protocol/proto/CacheReport.proto new file mode 100644 index 00000000..ac034059 --- /dev/null +++ b/protocol/proto/CacheReport.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CacheReport { + optional bytes cache_id = 1; + optional string cache_path = 21; + optional string volatile_path = 22; + optional int64 max_cache_size = 2; + optional int64 free_space = 3; + optional int64 total_space = 4; + optional int64 cache_age = 5; + optional int64 num_users_with_locked_entries = 6; + optional int64 permanent_files = 7; + optional int64 permanent_size_kb = 8; + optional int64 unknown_permanent_files = 9; + optional int64 unknown_permanent_size_kb = 10; + optional int64 volatile_files = 11; + optional int64 volatile_size_kb = 12; + optional int64 unknown_volatile_files = 13; + optional int64 unknown_volatile_size_kb = 14; + optional int64 num_entries = 15; + optional int64 num_locked_entries = 16; + optional int64 num_locked_entries_current_user = 17; + optional int64 num_full_entries = 18; + optional int64 size_kb = 19; + optional int64 locked_size_kb = 20; +} diff --git a/protocol/proto/ClientLocale.proto b/protocol/proto/ClientLocale.proto new file mode 100644 index 00000000..a8e330b3 --- /dev/null +++ b/protocol/proto/ClientLocale.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ClientLocale { + optional string client_default_locale = 1; + optional string user_specified_locale = 2; +} diff --git a/protocol/proto/ColdStartupSequence.proto b/protocol/proto/ColdStartupSequence.proto new file mode 100644 index 00000000..cfeedee9 --- /dev/null +++ b/protocol/proto/ColdStartupSequence.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ColdStartupSequence { + optional string terminal_state = 1; + map steps = 2; + map metadata = 3; + optional string connection_type = 4; + optional string initial_application_state = 5; + optional string terminal_application_state = 6; + optional string view_load_sequence_id = 7; + optional int32 device_year_class = 8; + map subdurations = 9; +} diff --git a/protocol/proto/CollectionLevelDbInfo.proto b/protocol/proto/CollectionLevelDbInfo.proto new file mode 100644 index 00000000..4f222487 --- /dev/null +++ b/protocol/proto/CollectionLevelDbInfo.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CollectionLevelDbInfo { + optional string bucket = 1; + optional bool use_leveldb = 2; + optional bool migration_from_file_ok = 3; + optional bool index_check_ok = 4; + optional bool leveldb_works = 5; + optional bool already_migrated = 6; +} diff --git a/protocol/proto/CollectionOfflineControllerEmptyTrackList.proto b/protocol/proto/CollectionOfflineControllerEmptyTrackList.proto new file mode 100644 index 00000000..ee830433 --- /dev/null +++ b/protocol/proto/CollectionOfflineControllerEmptyTrackList.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CollectionOfflineControllerEmptyTrackList { + optional string link_type = 1; + optional bool consistent_with_collection = 2; + optional int64 collection_size = 3; +} diff --git a/protocol/proto/ConfigurationApplied.proto b/protocol/proto/ConfigurationApplied.proto new file mode 100644 index 00000000..40aad33c --- /dev/null +++ b/protocol/proto/ConfigurationApplied.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConfigurationApplied { + optional int64 last_rcs_fetch_time = 1; + optional string installation_id = 2; + repeated int32 policy_group_ids = 3; + optional string configuration_assignment_id = 4; + optional string rc_client_id = 5; + optional string rc_client_version = 6; + optional string platform = 7; + optional string fetch_type = 8; +} diff --git a/protocol/proto/ConfigurationFetched.proto b/protocol/proto/ConfigurationFetched.proto new file mode 100644 index 00000000..bb61a2e0 --- /dev/null +++ b/protocol/proto/ConfigurationFetched.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConfigurationFetched { + optional int64 last_rcs_fetch_time = 1; + optional string installation_id = 2; + optional string configuration_assignment_id = 3; + optional string property_set_id = 4; + optional string attributes_set_id = 5; + optional string rc_client_id = 6; + optional string rc_client_version = 7; + optional string rc_sdk_version = 8; + optional string platform = 9; + optional string fetch_type = 10; + optional int64 latency = 11; + optional int64 payload_size = 12; + optional int32 status_code = 13; + optional string error_reason = 14; + optional string error_message = 15; + optional string error_reason_configuration_resolve = 16; + optional string error_message_configuration_resolve = 17; + optional string error_reason_account_attributes = 18; + optional string error_message_account_attributes = 19; + optional int32 error_code_account_attributes = 20; + optional int32 error_code_configuration_resolve = 21; +} diff --git a/protocol/proto/ConfigurationFetchedNonAuth.proto b/protocol/proto/ConfigurationFetchedNonAuth.proto new file mode 100644 index 00000000..e28d1d39 --- /dev/null +++ b/protocol/proto/ConfigurationFetchedNonAuth.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConfigurationFetchedNonAuth { + optional int64 last_rcs_fetch_time = 1; + optional string installation_id = 2; + optional string configuration_assignment_id = 3; + optional string property_set_id = 4; + optional string attributes_set_id = 5; + optional string rc_client_id = 6; + optional string rc_client_version = 7; + optional string rc_sdk_version = 8; + optional string platform = 9; + optional string fetch_type = 10; + optional int64 latency = 11; + optional int64 payload_size = 12; + optional int32 status_code = 13; + optional string error_reason = 14; + optional string error_message = 15; + optional string error_reason_configuration_resolve = 16; + optional string error_message_configuration_resolve = 17; + optional string error_reason_account_attributes = 18; + optional string error_message_account_attributes = 19; + optional int32 error_code_account_attributes = 20; + optional int32 error_code_configuration_resolve = 21; +} diff --git a/protocol/proto/ConnectCredentialsRequest.proto b/protocol/proto/ConnectCredentialsRequest.proto new file mode 100644 index 00000000..d3e91cf3 --- /dev/null +++ b/protocol/proto/ConnectCredentialsRequest.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectCredentialsRequest { + optional string token_type = 1; + optional string client_id = 2; +} diff --git a/protocol/proto/ConnectDeviceDiscovered.proto b/protocol/proto/ConnectDeviceDiscovered.proto new file mode 100644 index 00000000..bb156ff7 --- /dev/null +++ b/protocol/proto/ConnectDeviceDiscovered.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectDeviceDiscovered { + optional string device_id = 1; + optional string discover_method = 2; + optional string discovered_device_id = 3; + optional string discovered_device_type = 4; + optional string discovered_library_version = 5; + optional string discovered_brand_display_name = 6; + optional string discovered_model_display_name = 7; + optional string discovered_client_id = 8; + optional string discovered_product_id = 9; + optional string discovered_device_availablilty = 10; + optional string discovered_device_public_key = 11; + optional bool capabilities_resolved = 12; +} diff --git a/protocol/proto/ConnectDialError.proto b/protocol/proto/ConnectDialError.proto new file mode 100644 index 00000000..90a8f36a --- /dev/null +++ b/protocol/proto/ConnectDialError.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectDialError { + optional string type = 1; + optional string request = 2; + optional string response = 3; + optional int64 error = 4; + optional string context = 5; +} diff --git a/protocol/proto/ConnectMdnsPacketParseError.proto b/protocol/proto/ConnectMdnsPacketParseError.proto new file mode 100644 index 00000000..e7685828 --- /dev/null +++ b/protocol/proto/ConnectMdnsPacketParseError.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectMdnsPacketParseError { + optional string type = 1; + optional string buffer = 2; + optional string ttl = 3; + optional string txt = 4; + optional string host = 5; + optional string discovery_name = 6; + optional string context = 7; +} diff --git a/protocol/proto/ConnectPullFailure.proto b/protocol/proto/ConnectPullFailure.proto new file mode 100644 index 00000000..fc1f9819 --- /dev/null +++ b/protocol/proto/ConnectPullFailure.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectPullFailure { + optional bytes transfer_data = 1; + optional int64 error_code = 2; + map reasons = 3; +} diff --git a/protocol/proto/ConnectTransferResult.proto b/protocol/proto/ConnectTransferResult.proto new file mode 100644 index 00000000..9239e845 --- /dev/null +++ b/protocol/proto/ConnectTransferResult.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectTransferResult { + optional string result = 1; + optional string device_type = 2; + optional string discovery_class = 3; + optional string device_model = 4; + optional string device_brand = 5; + optional string device_software_version = 6; + optional int64 duration = 7; + optional string device_client_id = 8; + optional string transfer_intent_id = 9; + optional string transfer_debug_log = 10; + optional string error_code = 11; + optional int32 http_response_code = 12; + optional string initial_device_state = 13; + optional int32 retry_count = 14; + optional int32 login_retry_count = 15; + optional int64 login_duration = 16; + optional string target_device_id = 17; + optional bool target_device_is_local = 18; + optional string final_device_state = 19; +} diff --git a/protocol/proto/ConnectionError.proto b/protocol/proto/ConnectionError.proto new file mode 100644 index 00000000..8c1c35bd --- /dev/null +++ b/protocol/proto/ConnectionError.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectionError { + optional int64 error_code = 1; + optional string ap = 2; + optional string proxy = 3; +} diff --git a/protocol/proto/ConnectionInfo.proto b/protocol/proto/ConnectionInfo.proto new file mode 100644 index 00000000..2c830ed5 --- /dev/null +++ b/protocol/proto/ConnectionInfo.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectionInfo { + optional string ap = 1; + optional string proxy = 2; + optional bool user_initated_login = 3; + optional string reachability_type = 4; + optional string web_installer_unique_id = 5; + optional string ap_resolve_source = 6; + optional string address_type = 7; + optional bool ipv6_failed = 8; +} diff --git a/protocol/proto/ConnectionStateChange.proto b/protocol/proto/ConnectionStateChange.proto new file mode 100644 index 00000000..28e517c0 --- /dev/null +++ b/protocol/proto/ConnectionStateChange.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectionStateChange { + optional string type = 1; + optional string old = 2; + optional string new = 3; +} diff --git a/protocol/proto/DefaultConfigurationApplied.proto b/protocol/proto/DefaultConfigurationApplied.proto new file mode 100644 index 00000000..9236ecb9 --- /dev/null +++ b/protocol/proto/DefaultConfigurationApplied.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DefaultConfigurationApplied { + optional string installation_id = 1; + optional string configuration_assignment_id = 2; + optional string rc_client_id = 3; + optional string rc_client_version = 4; + optional string platform = 5; + optional string fetch_type = 6; + optional string reason = 7; +} diff --git a/protocol/proto/DesktopAuthenticationFailureNonAuth.proto b/protocol/proto/DesktopAuthenticationFailureNonAuth.proto new file mode 100644 index 00000000..e3b495ec --- /dev/null +++ b/protocol/proto/DesktopAuthenticationFailureNonAuth.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopAuthenticationFailureNonAuth { + optional string action_hash = 1; + optional string error_category = 2; + optional int32 error_code = 3; +} diff --git a/protocol/proto/DesktopAuthenticationSuccess.proto b/protocol/proto/DesktopAuthenticationSuccess.proto new file mode 100644 index 00000000..8814df79 --- /dev/null +++ b/protocol/proto/DesktopAuthenticationSuccess.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopAuthenticationSuccess { + optional string action_hash = 1; +} diff --git a/protocol/proto/DesktopDeviceInformation.proto b/protocol/proto/DesktopDeviceInformation.proto new file mode 100644 index 00000000..be503177 --- /dev/null +++ b/protocol/proto/DesktopDeviceInformation.proto @@ -0,0 +1,106 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopDeviceInformation { + optional string os_platform = 1; + optional string os_version = 2; + optional string computer_manufacturer = 3; + optional string mac_computer_model = 4; + optional string mac_computer_model_family = 5; + optional bool computer_has_internal_battery = 6; + optional bool computer_is_currently_running_on_battery_power = 7; + optional string mac_cpu_product_name = 8; + optional int64 mac_cpu_family_code = 9; + optional int64 cpu_num_physical_cores = 10; + optional int64 cpu_num_logical_cores = 11; + optional int64 cpu_clock_frequency_herz = 12; + optional int64 cpu_level_1_cache_size_bytes = 13; + optional int64 cpu_level_2_cache_size_bytes = 14; + optional int64 cpu_level_3_cache_size_bytes = 15; + optional bool cpu_is_64_bit_capable = 16; + optional int64 computer_ram_size_bytes = 17; + optional int64 computer_ram_speed_herz = 18; + optional int64 num_graphics_cards = 19; + optional int64 num_connected_screens = 20; + optional string app_screen_model_name = 21; + optional double app_screen_width_logical_points = 22; + optional double app_screen_height_logical_points = 23; + optional double mac_app_screen_scale_factor = 24; + optional double app_screen_physical_size_inches = 25; + optional int64 app_screen_bits_per_pixel = 26; + optional bool app_screen_supports_dci_p3_color_gamut = 27; + optional bool app_screen_is_built_in = 28; + optional string app_screen_graphics_card_model = 29; + optional int64 app_screen_graphics_card_vram_size_bytes = 30; + optional bool mac_app_screen_currently_contains_the_dock = 31; + optional bool mac_app_screen_currently_contains_active_menu_bar = 32; + optional bool boot_disk_is_known_ssd = 33; + optional string mac_boot_disk_connection_type = 34; + optional int64 boot_disk_capacity_bytes = 35; + optional int64 boot_disk_free_space_bytes = 36; + optional bool application_disk_is_same_as_boot_disk = 37; + optional bool application_disk_is_known_ssd = 38; + optional string mac_application_disk_connection_type = 39; + optional int64 application_disk_capacity_bytes = 40; + optional int64 application_disk_free_space_bytes = 41; + optional bool application_cache_disk_is_same_as_boot_disk = 42; + optional bool application_cache_disk_is_known_ssd = 43; + optional string mac_application_cache_disk_connection_type = 44; + optional int64 application_cache_disk_capacity_bytes = 45; + optional int64 application_cache_disk_free_space_bytes = 46; + optional bool has_pointing_device = 47; + optional bool has_builtin_pointing_device = 48; + optional bool has_touchpad = 49; + optional bool has_keyboard = 50; + optional bool has_builtin_keyboard = 51; + optional bool mac_has_touch_bar = 52; + optional bool has_touch_screen = 53; + optional bool has_pen_input = 54; + optional bool has_game_controller = 55; + optional bool has_bluetooth_support = 56; + optional int64 bluetooth_link_manager_version = 57; + optional string bluetooth_version_string = 58; + optional int64 num_audio_output_devices = 59; + optional string default_audio_output_device_name = 60; + optional string default_audio_output_device_manufacturer = 61; + optional double default_audio_output_device_current_sample_rate = 62; + optional int64 default_audio_output_device_current_bit_depth = 63; + optional int64 default_audio_output_device_current_buffer_size = 64; + optional int64 default_audio_output_device_current_num_channels = 65; + optional double default_audio_output_device_maximum_sample_rate = 66; + optional int64 default_audio_output_device_maximum_bit_depth = 67; + optional int64 default_audio_output_device_maximum_num_channels = 68; + optional bool default_audio_output_device_is_builtin = 69; + optional bool default_audio_output_device_is_virtual = 70; + optional string mac_default_audio_output_device_transport_type = 71; + optional string mac_default_audio_output_device_terminal_type = 72; + optional int64 num_video_capture_devices = 73; + optional string default_video_capture_device_manufacturer = 74; + optional string default_video_capture_device_model = 75; + optional string default_video_capture_device_name = 76; + optional int64 default_video_capture_device_image_width = 77; + optional int64 default_video_capture_device_image_height = 78; + optional string mac_default_video_capture_device_transport_type = 79; + optional bool default_video_capture_device_is_builtin = 80; + optional int64 num_active_network_interfaces = 81; + optional string mac_main_network_interface_name = 82; + optional string mac_main_network_interface_type = 83; + optional bool main_network_interface_supports_ipv4 = 84; + optional bool main_network_interface_supports_ipv6 = 85; + optional string main_network_interface_hardware_vendor = 86; + optional string main_network_interface_hardware_model = 87; + optional int64 main_network_interface_medium_speed_bps = 88; + optional int64 main_network_interface_link_speed_bps = 89; + optional double system_up_time_including_sleep_seconds = 90; + optional double system_up_time_awake_seconds = 91; + optional double app_up_time_including_sleep_seconds = 92; + optional string system_user_preferred_language_code = 93; + optional string system_user_preferred_locale = 94; + optional string mac_app_system_localization_language = 95; + optional string app_localization_language = 96; +} diff --git a/protocol/proto/DesktopGPUAccelerationInfo.proto b/protocol/proto/DesktopGPUAccelerationInfo.proto new file mode 100644 index 00000000..2fbaed08 --- /dev/null +++ b/protocol/proto/DesktopGPUAccelerationInfo.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopGPUAccelerationInfo { + optional bool is_enabled = 1; +} diff --git a/protocol/proto/DesktopHighMemoryUsage.proto b/protocol/proto/DesktopHighMemoryUsage.proto new file mode 100644 index 00000000..e55106e3 --- /dev/null +++ b/protocol/proto/DesktopHighMemoryUsage.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopHighMemoryUsage { + optional bool is_continuation_event = 1; + optional double sample_time_interval_seconds = 2; + optional int64 win_committed_bytes = 3; + optional int64 win_peak_committed_bytes = 4; + optional int64 win_working_set_bytes = 5; + optional int64 win_peak_working_set_bytes = 6; + optional int64 mac_virtual_size_bytes = 7; + optional int64 mac_resident_size_bytes = 8; + optional int64 mac_footprint_bytes = 9; +} diff --git a/protocol/proto/DesktopPerformanceIssue.proto b/protocol/proto/DesktopPerformanceIssue.proto new file mode 100644 index 00000000..4e70b435 --- /dev/null +++ b/protocol/proto/DesktopPerformanceIssue.proto @@ -0,0 +1,88 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopPerformanceIssue { + optional string event_type = 1; + optional bool is_continuation_event = 2; + optional double sample_time_interval_seconds = 3; + optional string computer_platform = 4; + optional double last_seen_main_thread_latency_seconds = 5; + optional double last_seen_core_thread_latency_seconds = 6; + optional double total_spotify_processes_cpu_load_percent = 7; + optional double main_process_cpu_load_percent = 8; + optional int64 mac_main_process_vm_size_bytes = 9; + optional int64 mac_main_process_resident_size_bytes = 10; + optional double mac_main_process_num_page_faults_per_second = 11; + optional double mac_main_process_num_pageins_per_second = 12; + optional double mac_main_process_num_cow_faults_per_second = 13; + optional double mac_main_process_num_context_switches_per_second = 14; + optional int64 main_process_num_total_threads = 15; + optional int64 main_process_num_running_threads = 16; + optional double renderer_process_cpu_load_percent = 17; + optional int64 mac_renderer_process_vm_size_bytes = 18; + optional int64 mac_renderer_process_resident_size_bytes = 19; + optional double mac_renderer_process_num_page_faults_per_second = 20; + optional double mac_renderer_process_num_pageins_per_second = 21; + optional double mac_renderer_process_num_cow_faults_per_second = 22; + optional double mac_renderer_process_num_context_switches_per_second = 23; + optional int64 renderer_process_num_total_threads = 24; + optional int64 renderer_process_num_running_threads = 25; + optional double system_total_cpu_load_percent = 26; + optional int64 mac_system_total_free_memory_size_bytes = 27; + optional int64 mac_system_total_active_memory_size_bytes = 28; + optional int64 mac_system_total_inactive_memory_size_bytes = 29; + optional int64 mac_system_total_wired_memory_size_bytes = 30; + optional int64 mac_system_total_compressed_memory_size_bytes = 31; + optional double mac_system_current_num_pageins_per_second = 32; + optional double mac_system_current_num_pageouts_per_second = 33; + optional double mac_system_current_num_page_faults_per_second = 34; + optional double mac_system_current_num_cow_faults_per_second = 35; + optional int64 system_current_num_total_processes = 36; + optional int64 system_current_num_total_threads = 37; + optional int64 computer_boot_disk_free_space_bytes = 38; + optional int64 application_disk_free_space_bytes = 39; + optional int64 application_cache_disk_free_space_bytes = 40; + optional bool computer_is_currently_running_on_battery_power = 41; + optional double computer_remaining_battery_capacity_percent = 42; + optional double computer_estimated_remaining_battery_time_seconds = 43; + optional int64 mac_computer_num_available_logical_cpu_cores_due_to_power_management = 44; + optional double mac_computer_current_processor_speed_percent_due_to_power_management = 45; + optional double mac_computer_current_cpu_time_limit_percent_due_to_power_management = 46; + optional double app_screen_width_points = 47; + optional double app_screen_height_points = 48; + optional double mac_app_screen_scale_factor = 49; + optional int64 app_screen_bits_per_pixel = 50; + optional bool app_screen_supports_dci_p3_color_gamut = 51; + optional bool app_screen_is_built_in = 52; + optional string app_screen_graphics_card_model = 53; + optional int64 app_screen_graphics_card_vram_size_bytes = 54; + optional double app_window_width_points = 55; + optional double app_window_height_points = 56; + optional double app_window_percentage_on_screen = 57; + optional double app_window_percentage_non_obscured = 58; + optional double system_up_time_including_sleep_seconds = 59; + optional double system_up_time_awake_seconds = 60; + optional double app_up_time_including_sleep_seconds = 61; + optional double computer_time_since_last_sleep_start_seconds = 62; + optional double computer_time_since_last_sleep_end_seconds = 63; + optional bool mac_system_user_session_is_currently_active = 64; + optional double mac_system_time_since_last_user_session_deactivation_seconds = 65; + optional double mac_system_time_since_last_user_session_reactivation_seconds = 66; + optional bool application_is_currently_active = 67; + optional bool application_window_is_currently_visible = 68; + optional bool mac_application_window_is_currently_minimized = 69; + optional bool application_window_is_currently_fullscreen = 70; + optional bool mac_application_is_currently_hidden = 71; + optional bool application_user_is_currently_logged_in = 72; + optional double application_time_since_last_user_log_in = 73; + optional double application_time_since_last_user_log_out = 74; + optional bool application_is_playing_now = 75; + optional string application_currently_playing_type = 76; + optional string application_currently_playing_uri = 77; + optional string application_currently_playing_ad_id = 78; +} diff --git a/protocol/proto/DesktopUpdateDownloadComplete.proto b/protocol/proto/DesktopUpdateDownloadComplete.proto new file mode 100644 index 00000000..bf1fe4d9 --- /dev/null +++ b/protocol/proto/DesktopUpdateDownloadComplete.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateDownloadComplete { + optional int64 revision = 1; + optional bool is_critical = 2; + optional string source = 3; + optional bool is_successful = 4; + optional bool is_employee = 5; +} diff --git a/protocol/proto/DesktopUpdateDownloadError.proto b/protocol/proto/DesktopUpdateDownloadError.proto new file mode 100644 index 00000000..8385d4a1 --- /dev/null +++ b/protocol/proto/DesktopUpdateDownloadError.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateDownloadError { + optional int64 revision = 1; + optional bool is_critical = 2; + optional string error_message = 3; + optional string source = 4; + optional bool is_employee = 5; +} diff --git a/protocol/proto/DesktopUpdateMessageAction.proto b/protocol/proto/DesktopUpdateMessageAction.proto new file mode 100644 index 00000000..3ff5efea --- /dev/null +++ b/protocol/proto/DesktopUpdateMessageAction.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateMessageAction { + optional bool will_download = 1; + optional int64 this_message_from_revision = 2; + optional int64 this_message_to_revision = 3; + optional bool is_critical = 4; + optional int64 already_downloaded_from_revision = 5; + optional int64 already_downloaded_to_revision = 6; + optional string source = 7; + optional bool is_employee = 8; +} diff --git a/protocol/proto/DesktopUpdateMessageProcessed.proto b/protocol/proto/DesktopUpdateMessageProcessed.proto new file mode 100644 index 00000000..71b2e766 --- /dev/null +++ b/protocol/proto/DesktopUpdateMessageProcessed.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateMessageProcessed { + optional bool success = 1; + optional string source = 2; + optional int64 revision = 3; + optional bool is_critical = 4; + optional string binary_hash = 5; + optional bool is_employee = 6; +} diff --git a/protocol/proto/DesktopUpdateResponse.proto b/protocol/proto/DesktopUpdateResponse.proto new file mode 100644 index 00000000..683672f2 --- /dev/null +++ b/protocol/proto/DesktopUpdateResponse.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateResponse { + optional int64 status_code = 1; + optional int64 request_time_ms = 2; + optional int64 payload_size = 3; + optional bool is_employee = 4; + optional string error_message = 5; +} diff --git a/protocol/proto/Download.proto b/protocol/proto/Download.proto new file mode 100644 index 00000000..0b3faee9 --- /dev/null +++ b/protocol/proto/Download.proto @@ -0,0 +1,55 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Download { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional int64 bytes_from_ap = 3; + optional int64 waste_from_ap = 4; + optional int64 reqs_from_ap = 5; + optional int64 error_from_ap = 6; + optional int64 bytes_from_cdn = 7; + optional int64 waste_from_cdn = 8; + optional int64 bytes_from_cache = 9; + optional int64 content_size = 10; + optional string content_type = 11; + optional int64 ap_initial_latency = 12; + optional int64 ap_max_latency = 13; + optional int64 ap_min_latency = 14; + optional double ap_avg_latency = 15; + optional int64 ap_median_latency = 16; + optional double ap_avg_bw = 17; + optional int64 cdn_initial_latency = 18; + optional int64 cdn_max_latency = 19; + optional int64 cdn_min_latency = 20; + optional double cdn_avg_latency = 21; + optional int64 cdn_median_latency = 22; + optional int64 cdn_64k_initial_latency = 23; + optional int64 cdn_64k_max_latency = 24; + optional int64 cdn_64k_min_latency = 25; + optional double cdn_64k_avg_latency = 26; + optional int64 cdn_64k_median_latency = 27; + optional double cdn_avg_bw = 28; + optional double cdn_initial_bw_estimate = 29; + optional string cdn_uri_scheme = 30; + optional string cdn_domain = 31; + optional string cdn_socket_reuse = 32; + optional int64 num_cache_error = 33; + optional int64 bytes_from_carrier = 34; + optional int64 bytes_from_unknown = 35; + optional int64 bytes_from_wifi = 36; + optional int64 bytes_from_ethernet = 37; + optional string request_type = 38; + optional int64 total_time = 39; + optional int64 bitrate = 40; + optional int64 reqs_from_cdn = 41; + optional int64 error_from_cdn = 42; + optional string file_origin = 43; + optional string initial_disk_state = 44; + optional bool locked = 45; +} diff --git a/protocol/proto/DrmRequestFailure.proto b/protocol/proto/DrmRequestFailure.proto new file mode 100644 index 00000000..8f7df231 --- /dev/null +++ b/protocol/proto/DrmRequestFailure.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DrmRequestFailure { + optional string reason = 1; + optional int64 error_code = 2; + optional bool fatal = 3; + optional bytes playback_id = 4; +} diff --git a/protocol/proto/EndAd.proto b/protocol/proto/EndAd.proto new file mode 100644 index 00000000..cff0b7b6 --- /dev/null +++ b/protocol/proto/EndAd.proto @@ -0,0 +1,34 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EndAd { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional bytes song_id = 3; + optional string source_start = 4; + optional string reason_start = 5; + optional string source_end = 6; + optional string reason_end = 7; + optional int64 bytes_played = 8; + optional int64 bytes_in_song = 9; + optional int64 ms_played = 10; + optional int64 ms_total_est = 11; + optional int64 ms_rcv_latency = 12; + optional int64 n_seekback = 13; + optional int64 ms_seekback = 14; + optional int64 n_seekfwd = 15; + optional int64 ms_seekfwd = 16; + optional int64 ms_latency = 17; + optional int64 n_stutter = 18; + optional int64 p_lowbuffer = 19; + optional bool skipped = 20; + optional bool ad_clicked = 21; + optional string token = 22; + optional int64 client_ad_count = 23; + optional int64 client_campaign_count = 24; +} diff --git a/protocol/proto/EventSenderInternalErrorNonAuth.proto b/protocol/proto/EventSenderInternalErrorNonAuth.proto new file mode 100644 index 00000000..e6fe182a --- /dev/null +++ b/protocol/proto/EventSenderInternalErrorNonAuth.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventSenderInternalErrorNonAuth { + optional string error_message = 1; + optional string error_type = 2; + optional string error_context = 3; + optional int32 error_code = 4; +} diff --git a/protocol/proto/EventSenderStats.proto b/protocol/proto/EventSenderStats.proto new file mode 100644 index 00000000..88be6fe1 --- /dev/null +++ b/protocol/proto/EventSenderStats.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventSenderStats { + map storage_size = 1; + map sequence_number_min = 2; + map sequence_number_next = 3; +} diff --git a/protocol/proto/EventSenderStats2NonAuth.proto b/protocol/proto/EventSenderStats2NonAuth.proto new file mode 100644 index 00000000..e55eaa66 --- /dev/null +++ b/protocol/proto/EventSenderStats2NonAuth.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventSenderStats2NonAuth { + repeated bytes sequence_ids = 1; + repeated string event_names = 2; + repeated int32 loss_stats_num_entries_per_sequence_id = 3; + repeated int32 loss_stats_event_name_index = 4; + repeated int64 loss_stats_storage_sizes = 5; + repeated int64 loss_stats_sequence_number_mins = 6; + repeated int64 loss_stats_sequence_number_nexts = 7; + repeated int32 ratelimiter_stats_event_name_index = 8; + repeated int64 ratelimiter_stats_drop_count = 9; + repeated int32 drop_list_num_entries_per_sequence_id = 10; + repeated int32 drop_list_event_name_index = 11; + repeated int64 drop_list_counts_total = 12; + repeated int64 drop_list_counts_unreported = 13; +} diff --git a/protocol/proto/ExternalDeviceInfo.proto b/protocol/proto/ExternalDeviceInfo.proto new file mode 100644 index 00000000..f590df22 --- /dev/null +++ b/protocol/proto/ExternalDeviceInfo.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ExternalDeviceInfo { + optional string type = 1; + optional string subtype = 2; + optional string reason = 3; + optional bool taken_over = 4; + optional int64 num_tracks = 5; + optional int64 num_purchased_tracks = 6; + optional int64 num_playlists = 7; + optional string error = 8; + optional bool full = 9; + optional bool sync_all = 10; +} diff --git a/protocol/proto/GetInfoFailures.proto b/protocol/proto/GetInfoFailures.proto new file mode 100644 index 00000000..868ae5b7 --- /dev/null +++ b/protocol/proto/GetInfoFailures.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message GetInfoFailures { + optional string device_id = 1; + optional int64 error_code = 2; + optional string request = 3; + optional string response_body = 4; + optional string context = 5; +} diff --git a/protocol/proto/HeadFileDownload.proto b/protocol/proto/HeadFileDownload.proto new file mode 100644 index 00000000..b0d72794 --- /dev/null +++ b/protocol/proto/HeadFileDownload.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message HeadFileDownload { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional string cdn_uri_scheme = 3; + optional string cdn_domain = 4; + optional int64 head_file_size = 5; + optional int64 bytes_downloaded = 6; + optional int64 bytes_wasted = 7; + optional int64 http_latency = 8; + optional int64 http_64k_latency = 9; + optional int64 total_time = 10; + optional int64 http_result = 11; + optional int64 error_code = 12; + optional int64 cached_bytes = 13; + optional int64 bytes_from_cache = 14; + optional string socket_reuse = 15; + optional string request_type = 16; + optional string initial_disk_state = 17; +} diff --git a/protocol/proto/LegacyEndSong.proto b/protocol/proto/LegacyEndSong.proto new file mode 100644 index 00000000..9366f18d --- /dev/null +++ b/protocol/proto/LegacyEndSong.proto @@ -0,0 +1,62 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LegacyEndSong { + optional int64 sequence_number = 1; + optional string sequence_id = 2; + optional bytes playback_id = 3; + optional bytes parent_playback_id = 4; + optional string source_start = 5; + optional string reason_start = 6; + optional string source_end = 7; + optional string reason_end = 8; + optional int64 bytes_played = 9; + optional int64 bytes_in_song = 10; + optional int64 ms_played = 11; + optional int64 ms_nominal_played = 12; + optional int64 ms_total_est = 13; + optional int64 ms_rcv_latency = 14; + optional int64 ms_overlapping = 15; + optional int64 n_seekback = 16; + optional int64 ms_seekback = 17; + optional int64 n_seekfwd = 18; + optional int64 ms_seekfwd = 19; + optional int64 ms_latency = 20; + optional int64 ui_latency = 21; + optional string player_id = 22; + optional int64 ms_key_latency = 23; + optional bool offline_key = 24; + optional bool cached_key = 25; + optional int64 n_stutter = 26; + optional int64 p_lowbuffer = 27; + optional bool shuffle = 28; + optional int64 max_continous = 29; + optional int64 union_played = 30; + optional int64 artificial_delay = 31; + optional int64 bitrate = 32; + optional string play_context = 33; + optional string audiocodec = 34; + optional string play_track = 35; + optional string display_track = 36; + optional bool offline = 37; + optional int64 offline_timestamp = 38; + optional bool incognito_mode = 39; + optional string provider = 40; + optional string referer = 41; + optional string referrer_version = 42; + optional string referrer_vendor = 43; + optional string transition = 44; + optional string streaming_rule = 45; + optional string gaia_dev_id = 46; + optional string accepted_tc = 47; + optional string promotion_type = 48; + optional string page_instance_id = 49; + optional string interaction_id = 50; + optional string parent_play_track = 51; + optional int64 core_version = 52; +} diff --git a/protocol/proto/LocalFileSyncError.proto b/protocol/proto/LocalFileSyncError.proto new file mode 100644 index 00000000..0403dba1 --- /dev/null +++ b/protocol/proto/LocalFileSyncError.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFileSyncError { + optional string error = 1; +} diff --git a/protocol/proto/LocalFilesError.proto b/protocol/proto/LocalFilesError.proto new file mode 100644 index 00000000..f49d805f --- /dev/null +++ b/protocol/proto/LocalFilesError.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFilesError { + optional int64 error_code = 1; + optional string context = 2; + optional string info = 3; +} diff --git a/protocol/proto/LocalFilesImport.proto b/protocol/proto/LocalFilesImport.proto new file mode 100644 index 00000000..4674e721 --- /dev/null +++ b/protocol/proto/LocalFilesImport.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFilesImport { + optional int64 tracks = 1; + optional int64 duplicate_tracks = 2; + optional int64 failed_tracks = 3; + optional int64 matched_tracks = 4; + optional string source = 5; + optional int64 invalid_tracks = 6; +} diff --git a/protocol/proto/LocalFilesReport.proto b/protocol/proto/LocalFilesReport.proto new file mode 100644 index 00000000..cd5c99d7 --- /dev/null +++ b/protocol/proto/LocalFilesReport.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFilesReport { + optional int64 total_tracks = 1; + optional int64 total_size = 2; + optional int64 owned_tracks = 3; + optional int64 owned_size = 4; + optional int64 tracks_not_found = 5; + optional int64 tracks_bad_format = 6; + optional int64 tracks_drm_protected = 7; + optional int64 tracks_unknown_pruned = 8; + optional int64 tracks_reallocated_repaired = 9; + optional int64 enabled_sources = 10; +} diff --git a/protocol/proto/LocalFilesSourceReport.proto b/protocol/proto/LocalFilesSourceReport.proto new file mode 100644 index 00000000..9dbd4bd9 --- /dev/null +++ b/protocol/proto/LocalFilesSourceReport.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFilesSourceReport { + optional string id = 1; + optional int64 tracks = 2; +} diff --git a/protocol/proto/MdnsLoginFailures.proto b/protocol/proto/MdnsLoginFailures.proto new file mode 100644 index 00000000..cd036561 --- /dev/null +++ b/protocol/proto/MdnsLoginFailures.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message MdnsLoginFailures { + optional string device_id = 1; + optional int64 error_code = 2; + optional string response_body = 3; + optional string request = 4; + optional int64 esdk_internal_error_code = 5; + optional string context = 6; +} diff --git a/protocol/proto/MetadataExtensionClientStatistic.proto b/protocol/proto/MetadataExtensionClientStatistic.proto new file mode 100644 index 00000000..253e0e18 --- /dev/null +++ b/protocol/proto/MetadataExtensionClientStatistic.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message MetadataExtensionClientStatistic { + optional bytes task_id = 1; + optional string feature_id = 2; + optional bool is_online_param = 3; + optional int32 num_extensions_with_etags = 4; + optional int32 num_extensions_requested = 5; + optional int32 num_extensions_needed = 6; + optional int32 num_uris_requested = 7; + optional int32 num_uris_needed = 8; + optional int32 num_prepared_requests = 9; + optional int32 num_sent_requests = 10; +} diff --git a/protocol/proto/Offline2ClientError.proto b/protocol/proto/Offline2ClientError.proto new file mode 100644 index 00000000..55c9ca24 --- /dev/null +++ b/protocol/proto/Offline2ClientError.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Offline2ClientError { + optional string error = 1; + optional string device_id = 2; + optional string cache_id = 3; +} diff --git a/protocol/proto/Offline2ClientEvent.proto b/protocol/proto/Offline2ClientEvent.proto new file mode 100644 index 00000000..b45bfd59 --- /dev/null +++ b/protocol/proto/Offline2ClientEvent.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Offline2ClientEvent { + optional string event = 1; + optional string device_id = 2; + optional string cache_id = 3; +} diff --git a/protocol/proto/OfflineError.proto b/protocol/proto/OfflineError.proto new file mode 100644 index 00000000..e669ce43 --- /dev/null +++ b/protocol/proto/OfflineError.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message OfflineError { + optional int64 error_code = 1; + optional string track = 2; +} diff --git a/protocol/proto/OfflineEvent.proto b/protocol/proto/OfflineEvent.proto new file mode 100644 index 00000000..e924f093 --- /dev/null +++ b/protocol/proto/OfflineEvent.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message OfflineEvent { + optional string event = 1; + optional string data = 2; +} diff --git a/protocol/proto/OfflineReport.proto b/protocol/proto/OfflineReport.proto new file mode 100644 index 00000000..2835f77d --- /dev/null +++ b/protocol/proto/OfflineReport.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message OfflineReport { + optional int64 total_num_tracks = 1; + optional int64 num_downloaded_tracks = 2; + optional int64 num_downloaded_tracks_keyless = 3; + optional int64 total_num_links = 4; + optional int64 total_num_links_keyless = 5; + map context_num_links_map = 6; + map linktype_num_tracks_map = 7; + optional int64 track_limit = 8; + optional int64 expiry = 9; + optional string change_reason = 10; + optional int64 offline_keys = 11; + optional int64 cached_keys = 12; + optional int64 total_num_episodes = 13; + optional int64 num_downloaded_episodes = 14; + optional int64 episode_limit = 15; + optional int64 episode_expiry = 16; +} diff --git a/protocol/proto/PlaybackError.proto b/protocol/proto/PlaybackError.proto new file mode 100644 index 00000000..6897490e --- /dev/null +++ b/protocol/proto/PlaybackError.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlaybackError { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional string track_id = 3; + optional int64 bitrate = 4; + optional int64 error_code = 5; + optional bool fatal = 6; + optional string audiocodec = 7; + optional bool external_track = 8; + optional int64 position_ms = 9; +} diff --git a/protocol/proto/PlaybackRetry.proto b/protocol/proto/PlaybackRetry.proto new file mode 100644 index 00000000..82b9e9b3 --- /dev/null +++ b/protocol/proto/PlaybackRetry.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlaybackRetry { + optional string track = 1; + optional bytes playback_id = 2; + optional string method = 3; + optional string status = 4; + optional string reason = 5; +} diff --git a/protocol/proto/PlaybackSegments.proto b/protocol/proto/PlaybackSegments.proto new file mode 100644 index 00000000..bd5026c7 --- /dev/null +++ b/protocol/proto/PlaybackSegments.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlaybackSegments { + optional bytes playback_id = 1; + optional string track_uri = 2; + optional bool overflow = 3; + optional string segments = 4; +} diff --git a/protocol/proto/PlayerStateRestore.proto b/protocol/proto/PlayerStateRestore.proto new file mode 100644 index 00000000..f9778a7a --- /dev/null +++ b/protocol/proto/PlayerStateRestore.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlayerStateRestore { + optional string error = 1; + optional int64 size = 2; + optional string context_uri = 3; + optional string state = 4; +} diff --git a/protocol/proto/PlaylistSyncEvent.proto b/protocol/proto/PlaylistSyncEvent.proto new file mode 100644 index 00000000..6f2a23e2 --- /dev/null +++ b/protocol/proto/PlaylistSyncEvent.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlaylistSyncEvent { + optional string playlist_id = 1; + optional bool is_playlist = 2; + optional int64 timestamp_ms = 3; + optional int32 error_code = 4; + optional string event_description = 5; +} diff --git a/protocol/proto/PodcastAdSegmentReceived.proto b/protocol/proto/PodcastAdSegmentReceived.proto new file mode 100644 index 00000000..036fb6d5 --- /dev/null +++ b/protocol/proto/PodcastAdSegmentReceived.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PodcastAdSegmentReceived { + optional string episode_uri = 1; + optional string playback_id = 2; + optional string slots = 3; + optional bool is_audio = 4; +} diff --git a/protocol/proto/Prefetch.proto b/protocol/proto/Prefetch.proto new file mode 100644 index 00000000..c388668a --- /dev/null +++ b/protocol/proto/Prefetch.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Prefetch { + optional int64 strategies = 1; + optional int64 strategy = 2; + optional bytes file_id = 3; + optional string track = 4; + optional int64 prefetch_index = 5; + optional int64 current_window_size = 6; + optional int64 max_window_size = 7; +} diff --git a/protocol/proto/PrefetchError.proto b/protocol/proto/PrefetchError.proto new file mode 100644 index 00000000..6a1e56b4 --- /dev/null +++ b/protocol/proto/PrefetchError.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PrefetchError { + optional int64 strategy = 1; + optional string description = 2; +} diff --git a/protocol/proto/ProductStateUcsVerification.proto b/protocol/proto/ProductStateUcsVerification.proto new file mode 100644 index 00000000..95257538 --- /dev/null +++ b/protocol/proto/ProductStateUcsVerification.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ProductStateUcsVerification { + map additional_entries = 1; + map missing_entries = 2; + optional string fetch_type = 3; +} diff --git a/protocol/proto/PubSubCountPerIdent.proto b/protocol/proto/PubSubCountPerIdent.proto new file mode 100644 index 00000000..a2d1e097 --- /dev/null +++ b/protocol/proto/PubSubCountPerIdent.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PubSubCountPerIdent { + optional string ident_filter = 1; + optional int32 no_of_messages_received = 2; + optional int32 no_of_failed_conversions = 3; +} diff --git a/protocol/proto/RawCoreStream.proto b/protocol/proto/RawCoreStream.proto new file mode 100644 index 00000000..848b945b --- /dev/null +++ b/protocol/proto/RawCoreStream.proto @@ -0,0 +1,52 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RawCoreStream { + optional bytes playback_id = 1; + optional bytes parent_playback_id = 2; + optional string video_session_id = 3; + optional bytes media_id = 4; + optional string media_type = 5; + optional string feature_identifier = 6; + optional string feature_version = 7; + optional string view_uri = 8; + optional string source_start = 9; + optional string reason_start = 10; + optional string source_end = 11; + optional string reason_end = 12; + optional int64 playback_start_time = 13; + optional int32 ms_played = 14; + optional int32 ms_played_nominal = 15; + optional int32 ms_played_overlapping = 16; + optional int32 ms_played_video = 17; + optional int32 ms_played_background = 18; + optional int32 ms_played_fullscreen = 19; + optional bool live = 20; + optional bool shuffle = 21; + optional string audio_format = 22; + optional string play_context = 23; + optional string content_uri = 24; + optional string displayed_content_uri = 25; + optional bool content_is_downloaded = 26; + optional bool incognito_mode = 27; + optional string provider = 28; + optional string referrer = 29; + optional string referrer_version = 30; + optional string referrer_vendor = 31; + optional string streaming_rule = 32; + optional string connect_controller_device_id = 33; + optional string page_instance_id = 34; + optional string interaction_id = 35; + optional string parent_content_uri = 36; + optional int64 core_version = 37; + optional string core_bundle = 38; + optional bool is_assumed_premium = 39; + optional int32 ms_played_external = 40; + optional string local_content_uri = 41; + optional bool client_offline_at_stream_start = 42; +} diff --git a/protocol/proto/ReachabilityChanged.proto b/protocol/proto/ReachabilityChanged.proto new file mode 100644 index 00000000..d8e3bc10 --- /dev/null +++ b/protocol/proto/ReachabilityChanged.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ReachabilityChanged { + optional string type = 1; + optional string info = 2; +} diff --git a/protocol/proto/RejectedClientEventNonAuth.proto b/protocol/proto/RejectedClientEventNonAuth.proto new file mode 100644 index 00000000..d592809b --- /dev/null +++ b/protocol/proto/RejectedClientEventNonAuth.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RejectedClientEventNonAuth { + optional string reject_reason = 1; + optional string event_name = 2; +} diff --git a/protocol/proto/RemainingSkips.proto b/protocol/proto/RemainingSkips.proto new file mode 100644 index 00000000..d6ceebc0 --- /dev/null +++ b/protocol/proto/RemainingSkips.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RemainingSkips { + optional string interaction_id = 1; + optional int32 remaining_skips_before_skip = 2; + optional int32 remaining_skips_after_skip = 3; + repeated string interaction_ids = 4; +} diff --git a/protocol/proto/RequestAccounting.proto b/protocol/proto/RequestAccounting.proto new file mode 100644 index 00000000..897cffb9 --- /dev/null +++ b/protocol/proto/RequestAccounting.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RequestAccounting { + optional string request = 1; + optional int64 downloaded = 2; + optional int64 uploaded = 3; + optional int64 num_requests = 4; + optional string connection = 5; + optional string source_identifier = 6; + optional string reason = 7; + optional int64 duration_ms = 8; +} diff --git a/protocol/proto/RequestTime.proto b/protocol/proto/RequestTime.proto new file mode 100644 index 00000000..f0b7134f --- /dev/null +++ b/protocol/proto/RequestTime.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RequestTime { + optional string type = 1; + optional int64 first_byte = 2; + optional int64 last_byte = 3; + optional int64 size = 4; + optional int64 size_sent = 5; + optional bool error = 6; + optional string url = 7; + optional string verb = 8; + optional int64 payload_size_sent = 9; + optional int32 connection_reuse = 10; + optional double sampling_probability = 11; + optional bool cached = 12; +} diff --git a/protocol/proto/StartTrack.proto b/protocol/proto/StartTrack.proto new file mode 100644 index 00000000..5bbf5273 --- /dev/null +++ b/protocol/proto/StartTrack.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message StartTrack { + optional bytes playback_id = 1; + optional string context_player_session_id = 2; + optional int64 timestamp = 3; +} diff --git a/protocol/proto/Stutter.proto b/protocol/proto/Stutter.proto new file mode 100644 index 00000000..bd0b2980 --- /dev/null +++ b/protocol/proto/Stutter.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Stutter { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional string track = 3; + optional int64 buffer_size = 4; + optional int64 max_buffer_size = 5; + optional int64 file_byte_offset = 6; + optional int64 file_byte_total = 7; + optional int64 target_buffer = 8; + optional string audio_driver = 9; +} diff --git a/protocol/proto/TierFeatureFlags.proto b/protocol/proto/TierFeatureFlags.proto new file mode 100644 index 00000000..01f4311f --- /dev/null +++ b/protocol/proto/TierFeatureFlags.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message TierFeatureFlags { + optional bool ads = 1; + optional bool high_quality = 2; + optional bool offline = 3; + optional bool on_demand = 4; + optional string max_album_plays_consecutive = 5; + optional string max_album_plays_per_hour = 6; + optional string max_skips_per_hour = 7; + optional string max_track_plays_per_hour = 8; +} diff --git a/protocol/proto/TrackNotPlayed.proto b/protocol/proto/TrackNotPlayed.proto new file mode 100644 index 00000000..58c3ead2 --- /dev/null +++ b/protocol/proto/TrackNotPlayed.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message TrackNotPlayed { + optional bytes playback_id = 1; + optional string source_start = 2; + optional string reason_start = 3; + optional string source_end = 4; + optional string reason_end = 5; + optional string play_context = 6; + optional string play_track = 7; + optional string display_track = 8; + optional string provider = 9; + optional string referer = 10; + optional string referrer_version = 11; + optional string referrer_vendor = 12; + optional string gaia_dev_id = 13; + optional string reason_not_played = 14; +} diff --git a/protocol/proto/TrackStuck.proto b/protocol/proto/TrackStuck.proto new file mode 100644 index 00000000..566d6494 --- /dev/null +++ b/protocol/proto/TrackStuck.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message TrackStuck { + optional string track = 1; + optional bytes playback_id = 2; + optional string source_start = 3; + optional string reason_start = 4; + optional bool offline = 5; + optional int64 position = 6; + optional int64 count = 7; + optional string audio_driver = 8; +} diff --git a/protocol/proto/WindowSize.proto b/protocol/proto/WindowSize.proto new file mode 100644 index 00000000..7860b1e7 --- /dev/null +++ b/protocol/proto/WindowSize.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message WindowSize { + optional int64 width = 1; + optional int64 height = 2; + optional int64 mode = 3; + optional int64 duration = 4; +} diff --git a/protocol/proto/ad-hermes-proxy.proto b/protocol/proto/ad-hermes-proxy.proto deleted file mode 100644 index 219bbcbf..00000000 --- a/protocol/proto/ad-hermes-proxy.proto +++ /dev/null @@ -1,51 +0,0 @@ -syntax = "proto2"; - -message Rule { - optional string type = 0x1; - optional uint32 times = 0x2; - optional uint64 interval = 0x3; -} - -message AdRequest { - optional string client_language = 0x1; - optional string product = 0x2; - optional uint32 version = 0x3; - optional string type = 0x4; - repeated string avoidAds = 0x5; -} - -message AdQueueResponse { - repeated AdQueueEntry adQueueEntry = 0x1; -} - -message AdFile { - optional string id = 0x1; - optional string format = 0x2; -} - -message AdQueueEntry { - optional uint64 start_time = 0x1; - optional uint64 end_time = 0x2; - optional double priority = 0x3; - optional string token = 0x4; - optional uint32 ad_version = 0x5; - optional string id = 0x6; - optional string type = 0x7; - optional string campaign = 0x8; - optional string advertiser = 0x9; - optional string url = 0xa; - optional uint64 duration = 0xb; - optional uint64 expiry = 0xc; - optional string tracking_url = 0xd; - optional string banner_type = 0xe; - optional string html = 0xf; - optional string image = 0x10; - optional string background_image = 0x11; - optional string background_url = 0x12; - optional string background_color = 0x13; - optional string title = 0x14; - optional string caption = 0x15; - repeated AdFile file = 0x16; - repeated Rule rule = 0x17; -} - diff --git a/protocol/proto/apiv1.proto b/protocol/proto/apiv1.proto new file mode 100644 index 00000000..2d8b9c28 --- /dev/null +++ b/protocol/proto/apiv1.proto @@ -0,0 +1,108 @@ +// No longer present in Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.offline.proto; + +import "google/protobuf/timestamp.proto"; +import "offline.proto"; + +option optimize_for = CODE_SIZE; + +message ListDevicesRequest { + string user_id = 1; +} + +message ListDevicesResponse { + repeated Device devices = 1; +} + +message PutDeviceRequest { + string user_id = 1; + + Body body = 2; + message Body { + Device device = 1; + } +} + +message BasicDeviceRequest { + DeviceKey key = 1; +} + +message GetDeviceResponse { + Device device = 1; +} + +message RemoveDeviceRequest { + DeviceKey key = 1; + bool is_force_remove = 2; +} + +message OfflineEnableDeviceResponse { + Restrictions restrictions = 1; +} + +message ListResourcesResponse { + repeated Resource resources = 1; + google.protobuf.Timestamp server_time = 2; +} + +message WriteResourcesRequest { + DeviceKey key = 1; + + Body body = 2; + message Body { + repeated ResourceOperation operations = 1; + string source_device_id = 2; + string source_cache_id = 3; + } +} + +message ResourcesUpdate { + string source_device_id = 1; + string source_cache_id = 2; +} + +message DeltaResourcesRequest { + DeviceKey key = 1; + + Body body = 2; + message Body { + google.protobuf.Timestamp last_known_server_time = 1; + } +} + +message DeltaResourcesResponse { + bool delta_update_possible = 1; + repeated ResourceOperation operations = 2; + google.protobuf.Timestamp server_time = 3; +} + +message GetResourceRequest { + DeviceKey key = 1; + string uri = 2; +} + +message GetResourceResponse { + Resource resource = 1; +} + +message WriteResourcesDetailsRequest { + DeviceKey key = 1; + + Body body = 2; + message Body { + repeated Resource resources = 1; + } +} + +message GetResourceForDevicesRequest { + string user_id = 1; + string uri = 2; +} + +message GetResourceForDevicesResponse { + repeated Device devices = 1; + repeated ResourceForDevice resources = 2; +} diff --git a/protocol/proto/app_state.proto b/protocol/proto/app_state.proto new file mode 100644 index 00000000..fb4b07a4 --- /dev/null +++ b/protocol/proto/app_state.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.offline.proto; + +option optimize_for = CODE_SIZE; + +message AppStateRequest { + AppState state = 1; +} + +enum AppState { + UNKNOWN = 0; + BACKGROUND = 1; + FOREGROUND = 2; +} diff --git a/protocol/proto/appstore.proto b/protocol/proto/appstore.proto deleted file mode 100644 index bddaaf30..00000000 --- a/protocol/proto/appstore.proto +++ /dev/null @@ -1,95 +0,0 @@ -syntax = "proto2"; - -message AppInfo { - optional string identifier = 0x1; - optional int32 version_int = 0x2; -} - -message AppInfoList { - repeated AppInfo items = 0x1; -} - -message SemanticVersion { - optional int32 major = 0x1; - optional int32 minor = 0x2; - optional int32 patch = 0x3; -} - -message RequestHeader { - optional string market = 0x1; - optional Platform platform = 0x2; - enum Platform { - WIN32_X86 = 0x0; - OSX_X86 = 0x1; - LINUX_X86 = 0x2; - IPHONE_ARM = 0x3; - SYMBIANS60_ARM = 0x4; - OSX_POWERPC = 0x5; - ANDROID_ARM = 0x6; - WINCE_ARM = 0x7; - LINUX_X86_64 = 0x8; - OSX_X86_64 = 0x9; - PALM_ARM = 0xa; - LINUX_SH = 0xb; - FREEBSD_X86 = 0xc; - FREEBSD_X86_64 = 0xd; - BLACKBERRY_ARM = 0xe; - SONOS_UNKNOWN = 0xf; - LINUX_MIPS = 0x10; - LINUX_ARM = 0x11; - LOGITECH_ARM = 0x12; - LINUX_BLACKFIN = 0x13; - ONKYO_ARM = 0x15; - QNXNTO_ARM = 0x16; - BADPLATFORM = 0xff; - } - optional AppInfoList app_infos = 0x6; - optional string bridge_identifier = 0x7; - optional SemanticVersion bridge_version = 0x8; - optional DeviceClass device_class = 0x9; - enum DeviceClass { - DESKTOP = 0x1; - TABLET = 0x2; - MOBILE = 0x3; - WEB = 0x4; - TV = 0x5; - } -} - -message AppItem { - optional string identifier = 0x1; - optional Requirement requirement = 0x2; - enum Requirement { - REQUIRED_INSTALL = 0x1; - LAZYLOAD = 0x2; - OPTIONAL_INSTALL = 0x3; - } - optional string manifest = 0x4; - optional string checksum = 0x5; - optional string bundle_uri = 0x6; - optional string small_icon_uri = 0x7; - optional string large_icon_uri = 0x8; - optional string medium_icon_uri = 0x9; - optional Type bundle_type = 0xa; - enum Type { - APPLICATION = 0x0; - FRAMEWORK = 0x1; - BRIDGE = 0x2; - } - optional SemanticVersion version = 0xb; - optional uint32 ttl_in_seconds = 0xc; - optional IdentifierList categories = 0xd; -} - -message AppList { - repeated AppItem items = 0x1; -} - -message IdentifierList { - repeated string identifiers = 0x1; -} - -message BannerConfig { - optional string json = 0x1; -} - diff --git a/protocol/proto/audio_files_extension.proto b/protocol/proto/audio_files_extension.proto new file mode 100644 index 00000000..32efd995 --- /dev/null +++ b/protocol/proto/audio_files_extension.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.extendedmetadata.audiofiles; + +import "metadata.proto"; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.audiophile.proto"; + +message NormalizationParams { + float loudness_db = 1; + float true_peak_db = 2; +} + +message ExtendedAudioFile { + metadata.AudioFile file = 1; + NormalizationParams file_normalization_params = 2; + NormalizationParams album_normalization_params = 3; +} + +message AudioFilesExtensionResponse { + repeated ExtendedAudioFile files = 1; + NormalizationParams default_file_normalization_params = 2; + NormalizationParams default_album_normalization_params = 3; +} diff --git a/protocol/proto/autodownload_backend_service.proto b/protocol/proto/autodownload_backend_service.proto new file mode 100644 index 00000000..fa088feb --- /dev/null +++ b/protocol/proto/autodownload_backend_service.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.autodownloadservice.v1.proto; + +import "google/protobuf/timestamp.proto"; + +message Identifiers { + string device_id = 1; + string cache_id = 2; +} + +message Settings { + oneof episode_download { + bool most_recent_no_limit = 1; + int32 most_recent_count = 2; + } +} + +message SetSettingsRequest { + Identifiers identifiers = 1; + Settings settings = 2; + google.protobuf.Timestamp client_timestamp = 3; +} + +message GetSettingsRequest { + Identifiers identifiers = 1; +} + +message GetSettingsResponse { + Settings settings = 1; +} + +message ShowRequest { + Identifiers identifiers = 1; + string show_uri = 2; + google.protobuf.Timestamp client_timestamp = 3; +} + +message ReplaceIdentifiersRequest { + Identifiers old_identifiers = 1; + Identifiers new_identifiers = 2; +} + +message PendingItem { + google.protobuf.Timestamp client_timestamp = 1; + + oneof pending { + bool is_removed = 2; + Settings settings = 3; + } +} diff --git a/protocol/proto/autodownload_config_common.proto b/protocol/proto/autodownload_config_common.proto new file mode 100644 index 00000000..9d923f04 --- /dev/null +++ b/protocol/proto/autodownload_config_common.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.autodownload_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.autodownload.esperanto.proto"; + +message AutoDownloadGlobalConfig { + uint32 number_of_episodes = 1; +} + +message AutoDownloadShowConfig { + string uri = 1; + bool active = 2; +} diff --git a/protocol/proto/autodownload_config_get_request.proto b/protocol/proto/autodownload_config_get_request.proto new file mode 100644 index 00000000..be4681bb --- /dev/null +++ b/protocol/proto/autodownload_config_get_request.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.autodownload_esperanto.proto; + +import "autodownload_config_common.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.autodownload.esperanto.proto"; + +message AutoDownloadGetRequest { + repeated string uri = 1; +} + +message AutoDownloadGetResponse { + AutoDownloadGlobalConfig global = 1; + repeated AutoDownloadShowConfig show = 2; + string error = 99; +} diff --git a/protocol/proto/autodownload_config_set_request.proto b/protocol/proto/autodownload_config_set_request.proto new file mode 100644 index 00000000..2adcbeab --- /dev/null +++ b/protocol/proto/autodownload_config_set_request.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.autodownload_esperanto.proto; + +import "autodownload_config_common.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.autodownload.esperanto.proto"; + +message AutoDownloadSetRequest { + oneof config { + AutoDownloadGlobalConfig global = 1; + AutoDownloadShowConfig show = 2; + } +} + +message AutoDownloadSetResponse { + string error = 99; +} diff --git a/protocol/proto/automix_mode.proto b/protocol/proto/automix_mode.proto new file mode 100644 index 00000000..d0d7f938 --- /dev/null +++ b/protocol/proto/automix_mode.proto @@ -0,0 +1,41 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.automix.proto; + +option optimize_for = CODE_SIZE; + +message AutomixConfig { + TransitionType transition_type = 1; + string fade_out_curves = 2; + string fade_in_curves = 3; + int32 beats_min = 4; + int32 beats_max = 5; + int32 fade_duration_max_ms = 6; +} + +message AutomixMode { + AutomixStyle style = 1; + AutomixConfig config = 2; + AutomixConfig ml_config = 3; + AutomixConfig shuffle_config = 4; + AutomixConfig shuffle_ml_config = 5; +} + +enum AutomixStyle { + NONE = 0; + DEFAULT = 1; + REGULAR = 2; + AIRBAG = 3; + RADIO_AIRBAG = 4; + SLEEP = 5; + MIXED = 6; + CUSTOM = 7; +} + +enum TransitionType { + CUEPOINTS = 0; + CROSSFADE = 1; + GAPLESS = 2; +} diff --git a/protocol/proto/autoplay_context_request.proto b/protocol/proto/autoplay_context_request.proto new file mode 100644 index 00000000..4fa4b0bc --- /dev/null +++ b/protocol/proto/autoplay_context_request.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message AutoplayContextRequest { + required string context_uri = 1; + repeated string recent_track_uri = 2; +} diff --git a/protocol/proto/autoplay_node.proto b/protocol/proto/autoplay_node.proto new file mode 100644 index 00000000..18709f12 --- /dev/null +++ b/protocol/proto/autoplay_node.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "logging_params.proto"; + +option optimize_for = CODE_SIZE; + +message AutoplayNode { + map filler_node = 1; + required bool is_playing_filler = 2; + required LoggingParams logging_params = 3; +} diff --git a/protocol/proto/canvas.proto b/protocol/proto/canvas.proto new file mode 100644 index 00000000..e008618e --- /dev/null +++ b/protocol/proto/canvas.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.context_track_exts.canvas; + +message Artist { + string uri = 1; + string name = 2; + string avatar = 3; +} + +message CanvasRecord { + string id = 1; + string url = 2; + string file_id = 3; + Type type = 4; + string entity_uri = 5; + Artist artist = 6; + bool explicit = 7; + string uploaded_by = 8; + string etag = 9; + string canvas_uri = 11; + string storylines_id = 12; +} + +enum Type { + IMAGE = 0; + VIDEO = 1; + VIDEO_LOOPING = 2; + VIDEO_LOOPING_RANDOM = 3; + GIF = 4; +} diff --git a/protocol/proto/canvas_storage.proto b/protocol/proto/canvas_storage.proto new file mode 100644 index 00000000..e2f652c2 --- /dev/null +++ b/protocol/proto/canvas_storage.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.canvas.proto.storage; + +import "canvaz.proto"; + +option optimize_for = CODE_SIZE; + +message CanvasCacheEntry { + string entity_uri = 1; + uint64 expires_on_seconds = 2; + canvaz.cache.EntityCanvazResponse.Canvaz canvas = 3; +} + +message CanvasCacheFile { + repeated CanvasCacheEntry entries = 1; +} diff --git a/protocol/proto/canvaz-meta.proto b/protocol/proto/canvaz-meta.proto new file mode 100644 index 00000000..b3b55531 --- /dev/null +++ b/protocol/proto/canvaz-meta.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.canvaz; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.canvaz.proto"; + +enum Type { + IMAGE = 0; + VIDEO = 1; + VIDEO_LOOPING = 2; + VIDEO_LOOPING_RANDOM = 3; + GIF = 4; +} diff --git a/protocol/proto/canvaz.proto b/protocol/proto/canvaz.proto new file mode 100644 index 00000000..2493da95 --- /dev/null +++ b/protocol/proto/canvaz.proto @@ -0,0 +1,44 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.canvaz.cache; + +import "canvaz-meta.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.canvazcache.proto"; + +message Artist { + string uri = 1; + string name = 2; + string avatar = 3; +} + +message EntityCanvazResponse { + repeated Canvaz canvases = 1; + message Canvaz { + string id = 1; + string url = 2; + string file_id = 3; + spotify.canvaz.Type type = 4; + string entity_uri = 5; + Artist artist = 6; + bool explicit = 7; + string uploaded_by = 8; + string etag = 9; + string canvas_uri = 11; + string storylines_id = 12; + } + + int64 ttl_in_seconds = 2; +} + +message EntityCanvazRequest { + repeated Entity entities = 1; + message Entity { + string entity_uri = 1; + string etag = 2; + } +} diff --git a/protocol/proto/capping_data.proto b/protocol/proto/capping_data.proto new file mode 100644 index 00000000..dca6353a --- /dev/null +++ b/protocol/proto/capping_data.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.capper3; + +option java_multiple_files = true; +option java_package = "com.spotify.capper3.proto"; + +message ConsumeTokensRequest { + uint32 tokens = 1; +} + +message CappingData { + uint32 remaining_tokens = 1; + uint32 capacity = 2; + uint32 seconds_until_next_refill = 3; + uint32 refill_amount = 4; +} + +message ConsumeTokensResponse { + uint32 seconds_until_next_update = 1; + PlayCappingType capping_type = 2; + CappingData capping_data = 3; +} + +enum PlayCappingType { + NONE = 0; + LINEAR = 1; +} diff --git a/protocol/proto/claas.proto b/protocol/proto/claas.proto new file mode 100644 index 00000000..6006c17b --- /dev/null +++ b/protocol/proto/claas.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.claas.v1; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.claas.v1"; + +service ClaasService { + rpc PostLogs(PostLogsRequest) returns (PostLogsResponse); + rpc Watch(WatchRequest) returns (stream WatchResponse); +} + +message WatchRequest { + string user_id = 1; +} + +message WatchResponse { + repeated string logs = 1; +} + +message PostLogsRequest { + repeated string logs = 1; +} + +message PostLogsResponse { + +} diff --git a/protocol/proto/client-tts.proto b/protocol/proto/client-tts.proto new file mode 100644 index 00000000..0968f515 --- /dev/null +++ b/protocol/proto/client-tts.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.narration_injection.proto; + +import "tts-resolve.proto"; + +option optimize_for = CODE_SIZE; + +service ClientTtsService { + rpc GetTtsUrl(TtsRequest) returns (TtsResponse); +} + +message TtsRequest { + ResolveRequest.AudioFormat audio_format = 3; + string language = 4; + ResolveRequest.TtsVoice tts_voice = 5; + ResolveRequest.TtsProvider tts_provider = 6; + int32 sample_rate_hz = 7; + + oneof prompt { + string text = 1; + string ssml = 2; + } +} + +message TtsResponse { + string url = 1; +} diff --git a/protocol/proto/client_config.proto b/protocol/proto/client_config.proto new file mode 100644 index 00000000..b838873e --- /dev/null +++ b/protocol/proto/client_config.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.extendedmetadata.config.v1; + +option optimize_for = CODE_SIZE; + +message ClientConfig { + uint32 log_sampling_rate = 1; + uint32 avg_log_messages_per_minute = 2; + uint32 log_messages_burst_size = 3; +} diff --git a/protocol/proto/client_update.proto b/protocol/proto/client_update.proto new file mode 100644 index 00000000..fb93c9bd --- /dev/null +++ b/protocol/proto/client_update.proto @@ -0,0 +1,39 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.desktopupdate.proto; + +option java_multiple_files = true; +option java_outer_classname = "ClientUpdateProto"; +option java_package = "com.spotify.desktopupdate.proto"; + +message UpgradeSignedPart { + uint32 platform = 1; + uint64 version_from_from = 2; + uint64 version_from_to = 3; + uint64 target_version = 4; + string http_prefix = 5; + bytes binary_hash = 6; + ClientUpgradeType type = 7; + bytes file_id = 8; + uint32 delay = 9; + uint32 flags = 10; +} + +message UpgradeRequiredMessage { + bytes upgrade_signed_part = 10; + bytes signature = 20; + string http_suffix = 30; +} + +message UpdateQueryResponse { + UpgradeRequiredMessage upgrade_message_payload = 1; + uint32 poll_interval = 2; +} + +enum ClientUpgradeType { + INVALID = 0; + LOGIN_CRITICAL = 1; + NORMAL = 2; +} diff --git a/protocol/proto/clips_cover.proto b/protocol/proto/clips_cover.proto new file mode 100644 index 00000000..b129fb4a --- /dev/null +++ b/protocol/proto/clips_cover.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.clips; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "ClipsCoverProto"; +option java_package = "com.spotify.clips.proto"; + +message ClipsCover { + string image_url = 1; + string video_source_id = 2; +} diff --git a/protocol/proto/collection/album_collection_state.proto b/protocol/proto/collection/album_collection_state.proto new file mode 100644 index 00000000..1258961d --- /dev/null +++ b/protocol/proto/collection/album_collection_state.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message AlbumCollectionState { + optional string collection_link = 1; + optional uint32 num_tracks_in_collection = 2; + optional bool complete = 3; +} diff --git a/protocol/proto/collection/artist_collection_state.proto b/protocol/proto/collection/artist_collection_state.proto new file mode 100644 index 00000000..33ade56a --- /dev/null +++ b/protocol/proto/collection/artist_collection_state.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ArtistCollectionState { + optional string collection_link = 1; + optional bool followed = 2; + optional uint32 num_tracks_in_collection = 3; + optional uint32 num_albums_in_collection = 4; + optional bool is_banned = 5; + optional bool can_ban = 6; +} diff --git a/protocol/proto/collection/episode_collection_state.proto b/protocol/proto/collection/episode_collection_state.proto new file mode 100644 index 00000000..56fcc533 --- /dev/null +++ b/protocol/proto/collection/episode_collection_state.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option objc_class_prefix = "SPTCosmosUtil"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message EpisodeCollectionState { + optional bool is_following_show = 1; + optional bool is_new = 2; + optional bool is_in_listen_later = 3; +} diff --git a/protocol/proto/collection/show_collection_state.proto b/protocol/proto/collection/show_collection_state.proto new file mode 100644 index 00000000..d3904b51 --- /dev/null +++ b/protocol/proto/collection/show_collection_state.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ShowCollectionState { + optional bool is_in_collection = 1; +} diff --git a/protocol/proto/collection/track_collection_state.proto b/protocol/proto/collection/track_collection_state.proto new file mode 100644 index 00000000..68e42ed2 --- /dev/null +++ b/protocol/proto/collection/track_collection_state.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message TrackCollectionState { + optional bool is_in_collection = 1; + optional bool can_add_to_collection = 2; + optional bool is_banned = 3; + optional bool can_ban = 4; +} diff --git a/protocol/proto/collection2v2.proto b/protocol/proto/collection2v2.proto new file mode 100644 index 00000000..19530fe8 --- /dev/null +++ b/protocol/proto/collection2v2.proto @@ -0,0 +1,62 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.collection.proto.v2; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.collection2.v2.proto"; + +message PageRequest { + string username = 1; + string set = 2; + string pagination_token = 3; + int32 limit = 4; +} + +message CollectionItem { + string uri = 1; + int32 added_at = 2; + bool is_removed = 3; +} + +message PageResponse { + repeated CollectionItem items = 1; + string next_page_token = 2; + string sync_token = 3; +} + +message DeltaRequest { + string username = 1; + string set = 2; + string last_sync_token = 3; +} + +message DeltaResponse { + bool delta_update_possible = 1; + repeated CollectionItem items = 2; + string sync_token = 3; +} + +message WriteRequest { + string username = 1; + string set = 2; + repeated CollectionItem items = 3; + string client_update_id = 4; +} + +message PubSubUpdate { + string username = 1; + string set = 2; + repeated CollectionItem items = 3; + string client_update_id = 4; +} + +message InitializedRequest { + string username = 1; + string set = 2; +} + +message InitializedResponse { + bool initialized = 1; +} diff --git a/protocol/proto/collection_add_remove_items_request.proto b/protocol/proto/collection_add_remove_items_request.proto new file mode 100644 index 00000000..4dac680e --- /dev/null +++ b/protocol/proto/collection_add_remove_items_request.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "status.proto"; + +option optimize_for = CODE_SIZE; + +message CollectionAddRemoveItemsRequest { + repeated string item = 1; +} + +message CollectionAddRemoveItemsResponse { + Status status = 1; +} diff --git a/protocol/proto/collection_ban_request.proto b/protocol/proto/collection_ban_request.proto new file mode 100644 index 00000000..e64220df --- /dev/null +++ b/protocol/proto/collection_ban_request.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "status.proto"; + +option optimize_for = CODE_SIZE; + +message CollectionBanRequest { + string context_source = 1; + repeated string uri = 2; +} + +message CollectionBanResponse { + Status status = 1; + repeated bool success = 2; +} diff --git a/protocol/proto/collection_decoration_policy.proto b/protocol/proto/collection_decoration_policy.proto new file mode 100644 index 00000000..79b4b8cf --- /dev/null +++ b/protocol/proto/collection_decoration_policy.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "policy/artist_decoration_policy.proto"; +import "policy/album_decoration_policy.proto"; +import "policy/track_decoration_policy.proto"; + +option optimize_for = CODE_SIZE; + +message CollectionArtistDecorationPolicy { + cosmos_util.proto.ArtistCollectionDecorationPolicy collection_policy = 1; + cosmos_util.proto.ArtistSyncDecorationPolicy sync_policy = 2; + cosmos_util.proto.ArtistDecorationPolicy artist_policy = 3; + bool decorated = 4; +} + +message CollectionAlbumDecorationPolicy { + bool decorated = 1; + bool album_type = 2; + CollectionArtistDecorationPolicy artist_policy = 3; + CollectionArtistDecorationPolicy artists_policy = 4; + cosmos_util.proto.AlbumCollectionDecorationPolicy collection_policy = 5; + cosmos_util.proto.AlbumSyncDecorationPolicy sync_policy = 6; + cosmos_util.proto.AlbumDecorationPolicy album_policy = 7; +} + +message CollectionTrackDecorationPolicy { + cosmos_util.proto.TrackCollectionDecorationPolicy collection_policy = 1; + cosmos_util.proto.TrackSyncDecorationPolicy sync_policy = 2; + cosmos_util.proto.TrackDecorationPolicy track_policy = 3; + cosmos_util.proto.TrackPlayedStateDecorationPolicy played_state_policy = 4; + CollectionAlbumDecorationPolicy album_policy = 5; + cosmos_util.proto.ArtistDecorationPolicy artist_policy = 6; + bool decorated = 7; +} diff --git a/protocol/proto/collection_get_bans_request.proto b/protocol/proto/collection_get_bans_request.proto new file mode 100644 index 00000000..a67574ae --- /dev/null +++ b/protocol/proto/collection_get_bans_request.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "policy/track_decoration_policy.proto"; +import "policy/artist_decoration_policy.proto"; +import "metadata/track_metadata.proto"; +import "metadata/artist_metadata.proto"; +import "status.proto"; + +option objc_class_prefix = "SPTCollectionCosmos"; +option optimize_for = CODE_SIZE; + +message CollectionGetBansRequest { + cosmos_util.proto.TrackDecorationPolicy track_policy = 1; + cosmos_util.proto.ArtistDecorationPolicy artist_policy = 2; + string sort = 3; + bool timestamp = 4; + uint32 update_throttling = 5; +} + +message Item { + uint32 add_time = 1; + cosmos_util.proto.TrackMetadata track_metadata = 2; + cosmos_util.proto.ArtistMetadata artist_metadata = 3; +} + +message CollectionGetBansResponse { + Status status = 1; + repeated Item item = 2; +} diff --git a/protocol/proto/collection_index.proto b/protocol/proto/collection_index.proto new file mode 100644 index 00000000..ee6b3efc --- /dev/null +++ b/protocol/proto/collection_index.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection.proto; + +option optimize_for = CODE_SIZE; + +message IndexRepairerState { + bytes last_checked_uri = 1; + int64 last_full_check_finished_at = 2; +} + +message CollectionTrackEntry { + string uri = 1; + string track_name = 2; + string album_uri = 3; + string album_name = 4; + int32 disc_number = 5; + int32 track_number = 6; + string artist_uri = 7; + repeated string artist_name = 8; + int64 add_time = 9; +} + +message CollectionAlbumLikeEntry { + string uri = 1; + string album_name = 2; + string creator_uri = 4; + string creator_name = 5; + int64 add_time = 6; +} + +message CollectionArtistEntry { + string uri = 1; + string artist_name = 2; + int64 add_time = 4; +} diff --git a/protocol/proto/collection_item.proto b/protocol/proto/collection_item.proto new file mode 100644 index 00000000..4a98e9d0 --- /dev/null +++ b/protocol/proto/collection_item.proto @@ -0,0 +1,48 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "metadata/album_metadata.proto"; +import "metadata/artist_metadata.proto"; +import "metadata/track_metadata.proto"; +import "collection/artist_collection_state.proto"; +import "collection/album_collection_state.proto"; +import "collection/track_collection_state.proto"; +import "sync/artist_sync_state.proto"; +import "sync/album_sync_state.proto"; +import "sync/track_sync_state.proto"; +import "played_state/track_played_state.proto"; + +option optimize_for = CODE_SIZE; + +message CollectionTrack { + uint32 index = 1; + uint32 add_time = 2; + cosmos_util.proto.TrackMetadata track_metadata = 3; + cosmos_util.proto.TrackCollectionState track_collection_state = 4; + cosmos_util.proto.TrackPlayState track_play_state = 5; + cosmos_util.proto.TrackSyncState track_sync_state = 6; + bool decorated = 7; + CollectionAlbum album = 8; + string cover = 9; +} + +message CollectionAlbum { + uint32 add_time = 1; + cosmos_util.proto.AlbumMetadata album_metadata = 2; + cosmos_util.proto.AlbumCollectionState album_collection_state = 3; + cosmos_util.proto.AlbumSyncState album_sync_state = 4; + bool decorated = 5; + string album_type = 6; + repeated CollectionTrack track = 7; +} + +message CollectionArtist { + cosmos_util.proto.ArtistMetadata artist_metadata = 1; + cosmos_util.proto.ArtistCollectionState artist_collection_state = 2; + cosmos_util.proto.ArtistSyncState artist_sync_state = 3; + bool decorated = 4; + repeated CollectionAlbum album = 5; +} diff --git a/protocol/proto/collection_platform_requests.proto b/protocol/proto/collection_platform_requests.proto new file mode 100644 index 00000000..a855c217 --- /dev/null +++ b/protocol/proto/collection_platform_requests.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_platform.proto; + +option optimize_for = CODE_SIZE; + +message CollectionPlatformItemsRequest { + CollectionSet set = 1; + repeated string items = 2; +} + +enum CollectionSet { + UNKNOWN = 0; + SHOW = 1; + BAN = 2; + LISTENLATER = 3; + IGNOREINRECS = 4; + ENHANCED = 5; +} diff --git a/protocol/proto/collection_platform_responses.proto b/protocol/proto/collection_platform_responses.proto new file mode 100644 index 00000000..6b7716d8 --- /dev/null +++ b/protocol/proto/collection_platform_responses.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_platform.proto; + +option optimize_for = CODE_SIZE; + +message CollectionPlatformSimpleResponse { + string error_msg = 1; +} + +message CollectionPlatformItem { + string uri = 1; + int64 add_time = 2; +} + +message CollectionPlatformItemsResponse { + repeated CollectionPlatformItem items = 1; +} + +message CollectionPlatformContainsResponse { + repeated bool found = 1; +} diff --git a/protocol/proto/concat_cosmos.proto b/protocol/proto/concat_cosmos.proto new file mode 100644 index 00000000..7fe045a8 --- /dev/null +++ b/protocol/proto/concat_cosmos.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.concat_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message ConcatRequest { + string a = 1; + string b = 2; +} + +message ConcatWithSeparatorRequest { + string a = 1; + string b = 2; + string separator = 3; +} + +message ConcatResponse { + string concatenated = 1; +} diff --git a/protocol/proto/connect.proto b/protocol/proto/connect.proto new file mode 100644 index 00000000..d6485252 --- /dev/null +++ b/protocol/proto/connect.proto @@ -0,0 +1,239 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connectstate; + +import "player.proto"; +import "devices.proto"; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.connectstate.model"; + +message ClusterUpdate { + Cluster cluster = 1; + ClusterUpdateReason update_reason = 2; + string ack_id = 3; + repeated string devices_that_changed = 4; +} + +message PostCommandResponse { + string ack_id = 1; +} + +message Device { + DeviceInfo device_info = 1; + PlayerState player_state = 2; + PrivateDeviceInfo private_device_info = 3; + bytes transfer_data = 4; +} + +message Cluster { + int64 changed_timestamp_ms = 1; + string active_device_id = 2; + PlayerState player_state = 3; + map device = 4; + bytes transfer_data = 5; + uint64 transfer_data_timestamp = 6; + int64 not_playing_since_timestamp = 7; + bool need_full_player_state = 8; + int64 server_timestamp_ms = 9; +} + +message PutStateRequest { + string callback_url = 1; + Device device = 2; + MemberType member_type = 3; + bool is_active = 4; + PutStateReason put_state_reason = 5; + uint32 message_id = 6; + string last_command_sent_by_device_id = 7; + uint32 last_command_message_id = 8; + uint64 started_playing_at = 9; + uint64 has_been_playing_for_ms = 11; + uint64 client_side_timestamp = 12; + bool only_write_player_state = 13; +} + +message PrivateDeviceInfo { + string platform = 1; +} + +message SubscribeRequest { + string callback_url = 1; +} + +message DeviceInfo { + bool can_play = 1; + uint32 volume = 2; + string name = 3; + Capabilities capabilities = 4; + repeated DeviceMetadata metadata = 5; + string device_software_version = 6; + spotify.connectstate.devices.DeviceType device_type = 7; + string spirc_version = 9; + string device_id = 10; + bool is_private_session = 11; + bool is_social_connect = 12; + string client_id = 13; + string brand = 14; + string model = 15; + map metadata_map = 16; + string product_id = 17; + string deduplication_id = 18; + uint32 selected_alias_id = 19; + map device_aliases = 20; + bool is_offline = 21; + string public_ip = 22; + string license = 23; + bool is_group = 25; + bool is_dynamic_device = 26; + repeated string disallow_playback_reasons = 27; + repeated string disallow_transfer_reasons = 28; + + oneof _audio_output_device_info { + AudioOutputDeviceInfo audio_output_device_info = 24; + } +} + +message AudioOutputDeviceInfo { + oneof _audio_output_device_type { + AudioOutputDeviceType audio_output_device_type = 1; + } + + oneof _device_name { + string device_name = 2; + } +} + +message DeviceMetadata { + option deprecated = true; + string type = 1; + string metadata = 2; +} + +message Capabilities { + bool can_be_player = 2; + bool restrict_to_local = 3; + bool gaia_eq_connect_id = 5; + bool supports_logout = 6; + bool is_observable = 7; + int32 volume_steps = 8; + repeated string supported_types = 9; + bool command_acks = 10; + bool supports_rename = 11; + bool hidden = 12; + bool disable_volume = 13; + bool connect_disabled = 14; + bool supports_playlist_v2 = 15; + bool is_controllable = 16; + bool supports_external_episodes = 17; + bool supports_set_backend_metadata = 18; + bool supports_transfer_command = 19; + bool supports_command_request = 20; + bool is_voice_enabled = 21; + bool needs_full_player_state = 22; + bool supports_gzip_pushes = 23; + bool supports_set_options_command = 25; + CapabilitySupportDetails supports_hifi = 26; + string connect_capabilities = 27; + + //reserved 1, 4, 24, "supported_contexts", "supports_lossless_audio"; +} + +message CapabilitySupportDetails { + bool fully_supported = 1; + bool user_eligible = 2; + bool device_supported = 3; +} + +message ConnectCommandOptions { + int32 message_id = 1; + uint32 target_alias_id = 3; +} + +message LogoutCommand { + ConnectCommandOptions command_options = 1; +} + +message SetVolumeCommand { + int32 volume = 1; + ConnectCommandOptions command_options = 2; +} + +message RenameCommand { + string rename_to = 1; + ConnectCommandOptions command_options = 2; +} + +message ConnectPlayerCommand { + string player_command_json = 1; + ConnectCommandOptions command_options = 2; +} + +message SetBackendMetadataCommand { + map metadata = 1; +} + +message CommandAndSourceDevice { + string command = 1; + DeviceInfo source_device_info = 2; +} + +message ActiveDeviceUpdate { + string device_id = 1; +} + +message StartedPlayingEvent { + bytes user_info_header = 1; + string device_id = 2; +} + +enum AudioOutputDeviceType { + UNKNOWN_AUDIO_OUTPUT_DEVICE_TYPE = 0; + BUILT_IN_SPEAKER = 1; + LINE_OUT = 2; + BLUETOOTH = 3; + AIRPLAY = 4; +} + +enum PutStateReason { + UNKNOWN_PUT_STATE_REASON = 0; + SPIRC_HELLO = 1; + SPIRC_NOTIFY = 2; + NEW_DEVICE = 3; + PLAYER_STATE_CHANGED = 4; + VOLUME_CHANGED = 5; + PICKER_OPENED = 6; + BECAME_INACTIVE = 7; + ALIAS_CHANGED = 8; +} + +enum MemberType { + SPIRC_V2 = 0; + SPIRC_V3 = 1; + CONNECT_STATE = 2; + CONNECT_STATE_EXTENDED = 5; + ACTIVE_DEVICE_TRACKER = 6; + PLAY_TOKEN = 7; +} + +enum ClusterUpdateReason { + UNKNOWN_CLUSTER_UPDATE_REASON = 0; + DEVICES_DISAPPEARED = 1; + DEVICE_STATE_CHANGED = 2; + NEW_DEVICE_APPEARED = 3; + DEVICE_VOLUME_CHANGED = 4; + DEVICE_ALIAS_CHANGED = 5; +} + +enum SendCommandResult { + UNKNOWN_SEND_COMMAND_RESULT = 0; + SUCCESS = 1; + DEVICE_NOT_FOUND = 2; + CONTEXT_PLAYER_ERROR = 3; + DEVICE_DISAPPEARED = 4; + UPSTREAM_ERROR = 5; + DEVICE_DOES_NOT_SUPPORT_COMMAND = 6; + RATE_LIMITED = 7; +} diff --git a/protocol/proto/connectivity.proto b/protocol/proto/connectivity.proto new file mode 100644 index 00000000..ec85e4f9 --- /dev/null +++ b/protocol/proto/connectivity.proto @@ -0,0 +1,77 @@ +syntax = "proto3"; + +package spotify.clienttoken.data.v0; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.clienttoken.data.v0"; + +message ConnectivitySdkData { + PlatformSpecificData platform_specific_data = 1; + string device_id = 2; +} + +message PlatformSpecificData { + oneof data { + NativeAndroidData android = 1; + NativeIOSData ios = 2; + NativeDesktopMacOSData desktop_macos = 3; + NativeDesktopWindowsData desktop_windows = 4; + NativeDesktopLinuxData desktop_linux = 5; + } +} + +message NativeAndroidData { + Screen screen_dimensions = 1; + string android_version = 2; + int32 api_version = 3; + string device_name = 4; + string model_str = 5; + string vendor = 6; + string vendor_2 = 7; + int32 unknown_value_8 = 8; +} + +message NativeIOSData { + // https://developer.apple.com/documentation/uikit/uiuserinterfaceidiom + int32 user_interface_idiom = 1; + bool target_iphone_simulator = 2; + string hw_machine = 3; + string system_version = 4; + string simulator_model_identifier = 5; +} + +message NativeDesktopWindowsData { + int32 os_version = 1; + int32 os_build = 3; + // https://docs.microsoft.com/en-us/dotnet/api/system.platformid?view=net-6.0 + int32 platform_id = 4; + int32 unknown_value_5 = 5; + int32 unknown_value_6 = 6; + // https://docs.microsoft.com/en-us/dotnet/api/system.reflection.imagefilemachine?view=net-6.0 + int32 image_file_machine = 7; + // https://docs.microsoft.com/en-us/dotnet/api/system.reflection.portableexecutable.machine?view=net-6.0 + int32 pe_machine = 8; + bool unknown_value_10 = 10; +} + +message NativeDesktopLinuxData { + string system_name = 1; // uname -s + string system_release = 2; // -r + string system_version = 3; // -v + string hardware = 4; // -i +} + +message NativeDesktopMacOSData { + string system_version = 1; + string hw_model = 2; + string compiled_cpu_type = 3; +} + +message Screen { + int32 width = 1; + int32 height = 2; + int32 density = 3; + int32 unknown_value_4 = 4; + int32 unknown_value_5 = 5; +} diff --git a/protocol/proto/contains_request.proto b/protocol/proto/contains_request.proto new file mode 100644 index 00000000..cf59c5f5 --- /dev/null +++ b/protocol/proto/contains_request.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message ContainsRequest { + repeated string items = 1; +} + +message ContainsResponse { + repeated bool found = 1; +} diff --git a/protocol/proto/content_access_token_cosmos.proto b/protocol/proto/content_access_token_cosmos.proto new file mode 100644 index 00000000..2c98125b --- /dev/null +++ b/protocol/proto/content_access_token_cosmos.proto @@ -0,0 +1,36 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.contentaccesstoken.proto; + +import "google/protobuf/timestamp.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.contentaccesstoken.proto"; + +message ContentAccessTokenResponse { + Error error = 1; + ContentAccessToken content_access_token = 2; +} + +message ContentAccessToken { + string token = 1; + google.protobuf.Timestamp expires_at = 2; + google.protobuf.Timestamp refresh_at = 3; + repeated string domains = 4; +} + +message ContentAccessRefreshToken { + string token = 1; +} + +message IsEnabledResponse { + bool is_enabled = 1; +} + +message Error { + int32 error_code = 1; + string error_description = 2; +} diff --git a/protocol/proto/context.proto b/protocol/proto/context.proto new file mode 100644 index 00000000..eb022415 --- /dev/null +++ b/protocol/proto/context.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_page.proto"; +import "restrictions.proto"; + +option optimize_for = CODE_SIZE; + +message Context { + optional string uri = 1; + optional string url = 2; + map metadata = 3; + optional Restrictions restrictions = 4; + repeated ContextPage pages = 5; + optional bool loading = 6; +} diff --git a/protocol/proto/context_application_desktop.proto b/protocol/proto/context_application_desktop.proto new file mode 100644 index 00000000..04f443b2 --- /dev/null +++ b/protocol/proto/context_application_desktop.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ApplicationDesktop { + string version_string = 1; + int64 version_code = 2; +} diff --git a/protocol/proto/context_client_id.proto b/protocol/proto/context_client_id.proto new file mode 100644 index 00000000..bab3b6b8 --- /dev/null +++ b/protocol/proto/context_client_id.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ClientId { + bytes value = 1; +} diff --git a/protocol/proto/context_device_desktop.proto b/protocol/proto/context_device_desktop.proto new file mode 100644 index 00000000..a6b38372 --- /dev/null +++ b/protocol/proto/context_device_desktop.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DeviceDesktop { + string platform_type = 1; + string device_manufacturer = 2; + string device_model = 3; + string device_id = 4; + string os_version = 5; +} diff --git a/protocol/proto/context_index.proto b/protocol/proto/context_index.proto new file mode 100644 index 00000000..c7049eac --- /dev/null +++ b/protocol/proto/context_index.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message ContextIndex { + optional uint32 page = 1; + optional uint32 track = 2; +} diff --git a/protocol/proto/context_installation_id.proto b/protocol/proto/context_installation_id.proto new file mode 100644 index 00000000..08fe2580 --- /dev/null +++ b/protocol/proto/context_installation_id.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message InstallationId { + bytes value = 1; +} diff --git a/protocol/proto/context_monotonic_clock.proto b/protocol/proto/context_monotonic_clock.proto new file mode 100644 index 00000000..3ec525ff --- /dev/null +++ b/protocol/proto/context_monotonic_clock.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message MonotonicClock { + int64 id = 1; + int64 value = 2; +} diff --git a/protocol/proto/context_node.proto b/protocol/proto/context_node.proto new file mode 100644 index 00000000..82dd9d62 --- /dev/null +++ b/protocol/proto/context_node.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_processor.proto"; +import "play_origin.proto"; +import "prepare_play_options.proto"; +import "track_instance.proto"; +import "track_instantiator.proto"; + +option optimize_for = CODE_SIZE; + +message ContextNode { + optional TrackInstance current_track = 2; + optional TrackInstantiator instantiate = 3; + optional PreparePlayOptions prepare_options = 4; + optional PlayOrigin play_origin = 5; + optional ContextProcessor context_processor = 6; + optional string session_id = 7; + optional sint32 iteration = 8; + optional bool pending_pause = 9; +} diff --git a/protocol/proto/context_page.proto b/protocol/proto/context_page.proto new file mode 100644 index 00000000..b6e8ecdc --- /dev/null +++ b/protocol/proto/context_page.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message ContextPage { + optional string page_url = 1; + optional string next_page_url = 2; + map metadata = 3; + repeated ContextTrack tracks = 4; + optional bool loading = 5; +} diff --git a/protocol/proto/context_player_options.proto b/protocol/proto/context_player_options.proto new file mode 100644 index 00000000..57e069b5 --- /dev/null +++ b/protocol/proto/context_player_options.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message ContextPlayerOptions { + optional bool shuffling_context = 1; + optional bool repeating_context = 2; + optional bool repeating_track = 3; +} + +message ContextPlayerOptionOverrides { + optional bool shuffling_context = 1; + optional bool repeating_context = 2; + optional bool repeating_track = 3; +} diff --git a/protocol/proto/context_processor.proto b/protocol/proto/context_processor.proto new file mode 100644 index 00000000..2d931b0b --- /dev/null +++ b/protocol/proto/context_processor.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context.proto"; +import "context_view.proto"; +import "skip_to_track.proto"; + +option optimize_for = CODE_SIZE; + +message ContextProcessor { + optional Context context = 1; + optional context_view.proto.ContextView context_view = 2; + optional SkipToTrack pending_skip_to = 3; + optional string shuffle_seed = 4; + optional int32 index = 5; +} diff --git a/protocol/proto/context_sdk.proto b/protocol/proto/context_sdk.proto new file mode 100644 index 00000000..419f7aa5 --- /dev/null +++ b/protocol/proto/context_sdk.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Sdk { + string version_name = 1; + string type = 2; +} diff --git a/protocol/proto/context_time.proto b/protocol/proto/context_time.proto new file mode 100644 index 00000000..93749b41 --- /dev/null +++ b/protocol/proto/context_time.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Time { + int64 value = 1; +} diff --git a/protocol/proto/context_track.proto b/protocol/proto/context_track.proto new file mode 100644 index 00000000..e9d06f21 --- /dev/null +++ b/protocol/proto/context_track.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message ContextTrack { + optional string uri = 1; + optional string uid = 2; + optional bytes gid = 3; + map metadata = 4; +} diff --git a/protocol/proto/context_view.proto b/protocol/proto/context_view.proto new file mode 100644 index 00000000..0b78991a --- /dev/null +++ b/protocol/proto/context_view.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.context_view.proto; + +import "context_track.proto"; +import "context_view_cyclic_list.proto"; + +option optimize_for = CODE_SIZE; + +message ContextView { + map patch_map = 1; + optional uint32 iteration_size = 2; + optional cyclic_list.proto.CyclicEntryKeyList cyclic_list = 3; + + reserved 4; +} diff --git a/protocol/proto/context_view_cyclic_list.proto b/protocol/proto/context_view_cyclic_list.proto new file mode 100644 index 00000000..76cde3ed --- /dev/null +++ b/protocol/proto/context_view_cyclic_list.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.context_view.cyclic_list.proto; + +import "context_view_entry_key.proto"; + +option optimize_for = CODE_SIZE; + +message Instance { + optional context_view.proto.EntryKey item = 1; + optional int32 iteration = 2; +} + +message Patch { + optional int32 start = 1; + optional int32 end = 2; + repeated Instance instances = 3; +} + +message CyclicEntryKeyList { + optional context_view.proto.EntryKey delimiter = 1; + repeated context_view.proto.EntryKey items = 2; + optional Patch patch = 3; +} diff --git a/protocol/proto/context_view_entry.proto b/protocol/proto/context_view_entry.proto new file mode 100644 index 00000000..8451f481 --- /dev/null +++ b/protocol/proto/context_view_entry.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.context_view.proto; + +import "context_index.proto"; +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message Entry { + optional Type type = 1; + enum Type { + TRACK = 0; + DELIMITER = 1; + PAGE_PLACEHOLDER = 2; + CONTEXT_PLACEHOLDER = 3; + } + + optional player.proto.ContextTrack track = 2; + optional player.proto.ContextIndex index = 3; + optional int32 page_index = 4; + optional int32 absolute_index = 5; +} diff --git a/protocol/proto/context_view_entry_key.proto b/protocol/proto/context_view_entry_key.proto new file mode 100644 index 00000000..6c8a019f --- /dev/null +++ b/protocol/proto/context_view_entry_key.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.context_view.proto; + +import "context_view_entry.proto"; + +option optimize_for = CODE_SIZE; + +message EntryKey { + optional Entry.Type type = 1; + optional string data = 2; +} diff --git a/protocol/proto/cosmos_changes_request.proto b/protocol/proto/cosmos_changes_request.proto new file mode 100644 index 00000000..2e4b7040 --- /dev/null +++ b/protocol/proto/cosmos_changes_request.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.changes_request.proto; + +option objc_class_prefix = "SPTCollectionCosmosChanges"; +option optimize_for = CODE_SIZE; + +message Response { + +} diff --git a/protocol/proto/cosmos_decorate_request.proto b/protocol/proto/cosmos_decorate_request.proto new file mode 100644 index 00000000..9e586021 --- /dev/null +++ b/protocol/proto/cosmos_decorate_request.proto @@ -0,0 +1,71 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.decorate_request.proto; + +import "collection/album_collection_state.proto"; +import "collection/artist_collection_state.proto"; +import "collection/episode_collection_state.proto"; +import "collection/show_collection_state.proto"; +import "collection/track_collection_state.proto"; +import "played_state/episode_played_state.proto"; +import "played_state/show_played_state.proto"; +import "played_state/track_played_state.proto"; +import "sync/album_sync_state.proto"; +import "sync/artist_sync_state.proto"; +import "sync/episode_sync_state.proto"; +import "sync/track_sync_state.proto"; +import "metadata/album_metadata.proto"; +import "metadata/artist_metadata.proto"; +import "metadata/episode_metadata.proto"; +import "metadata/show_metadata.proto"; +import "metadata/track_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosDecorate"; +option optimize_for = CODE_SIZE; + +message Album { + optional cosmos_util.proto.AlbumMetadata album_metadata = 1; + optional cosmos_util.proto.AlbumCollectionState album_collection_state = 2; + optional cosmos_util.proto.AlbumSyncState album_offline_state = 3; + optional string link = 4; +} + +message Artist { + optional cosmos_util.proto.ArtistMetadata artist_metadata = 1; + optional cosmos_util.proto.ArtistCollectionState artist_collection_state = 2; + optional cosmos_util.proto.ArtistSyncState artist_offline_state = 3; + optional string link = 4; +} + +message Episode { + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 2; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 3; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 4; + optional string link = 5; +} + +message Show { + optional cosmos_util.proto.ShowMetadata show_metadata = 1; + optional cosmos_util.proto.ShowCollectionState show_collection_state = 2; + optional cosmos_util.proto.ShowPlayState show_play_state = 3; + optional string link = 4; +} + +message Track { + optional cosmos_util.proto.TrackMetadata track_metadata = 1; + optional cosmos_util.proto.TrackSyncState track_offline_state = 2; + optional cosmos_util.proto.TrackPlayState track_play_state = 3; + optional cosmos_util.proto.TrackCollectionState track_collection_state = 4; + optional string link = 5; +} + +message Response { + repeated Show show = 1; + repeated Episode episode = 2; + repeated Album album = 3; + repeated Artist artist = 4; + repeated Track track = 5; +} diff --git a/protocol/proto/cosmos_get_album_list_request.proto b/protocol/proto/cosmos_get_album_list_request.proto new file mode 100644 index 00000000..448dcd46 --- /dev/null +++ b/protocol/proto/cosmos_get_album_list_request.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.album_list_request.proto; + +import "collection/album_collection_state.proto"; +import "sync/album_sync_state.proto"; +import "metadata/album_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosAlbumList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 add_time = 3; + optional cosmos_util.proto.AlbumMetadata album_metadata = 4; + optional cosmos_util.proto.AlbumCollectionState album_collection_state = 5; + optional cosmos_util.proto.AlbumSyncState album_offline_state = 6; + optional string group_label = 7; +} + +message GroupHeader { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 length = 3; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; + optional string offline = 5; + optional uint32 sync_progress = 6; + repeated GroupHeader group_index = 7; +} diff --git a/protocol/proto/cosmos_get_artist_list_request.proto b/protocol/proto/cosmos_get_artist_list_request.proto new file mode 100644 index 00000000..1dfeedba --- /dev/null +++ b/protocol/proto/cosmos_get_artist_list_request.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.artist_list_request.proto; + +import "collection/artist_collection_state.proto"; +import "sync/artist_sync_state.proto"; +import "metadata/artist_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosArtistList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 add_time = 3; + optional cosmos_util.proto.ArtistMetadata artist_metadata = 4; + optional cosmos_util.proto.ArtistCollectionState artist_collection_state = 5; + optional cosmos_util.proto.ArtistSyncState artist_offline_state = 6; + optional string group_label = 7; +} + +message GroupHeader { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 length = 3; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; + optional string offline = 5; + optional uint32 sync_progress = 6; + repeated GroupHeader group_index = 7; +} diff --git a/protocol/proto/cosmos_get_episode_list_request.proto b/protocol/proto/cosmos_get_episode_list_request.proto new file mode 100644 index 00000000..437a621f --- /dev/null +++ b/protocol/proto/cosmos_get_episode_list_request.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.episode_list_request.proto; + +import "collection/episode_collection_state.proto"; +import "played_state/episode_played_state.proto"; +import "sync/episode_sync_state.proto"; +import "metadata/episode_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosEpisodeList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header = 1; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 5; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; +} diff --git a/protocol/proto/cosmos_get_show_list_request.proto b/protocol/proto/cosmos_get_show_list_request.proto new file mode 100644 index 00000000..e2b8a578 --- /dev/null +++ b/protocol/proto/cosmos_get_show_list_request.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.show_list_request.proto; + +import "collection/show_collection_state.proto"; +import "played_state/show_played_state.proto"; +import "metadata/show_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosShowList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional cosmos_util.proto.ShowMetadata show_metadata = 2; + optional cosmos_util.proto.ShowCollectionState show_collection_state = 3; + optional cosmos_util.proto.ShowPlayState show_play_state = 4; + optional uint32 headerless_index = 5; + optional uint32 add_time = 6; + optional bool has_new_episodes = 7; + optional uint64 latest_published_episode_date = 8; +} + +message Response { + repeated Item item = 1; + optional uint32 num_offlined_episodes = 2; + optional uint32 unfiltered_length = 3; + optional uint32 unranged_length = 4; + optional bool loading_contents = 5; +} diff --git a/protocol/proto/cosmos_get_tags_info_request.proto b/protocol/proto/cosmos_get_tags_info_request.proto new file mode 100644 index 00000000..5480c7bc --- /dev/null +++ b/protocol/proto/cosmos_get_tags_info_request.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_cosmos.tags_info_request.proto; + +option objc_class_prefix = "SPTCollectionCosmosTagsInfo"; +option optimize_for = CODE_SIZE; + +message Response { + bool is_synced = 1; +} diff --git a/protocol/proto/cosmos_get_track_list_metadata_request.proto b/protocol/proto/cosmos_get_track_list_metadata_request.proto new file mode 100644 index 00000000..a4586249 --- /dev/null +++ b/protocol/proto/cosmos_get_track_list_metadata_request.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.proto; + +option objc_class_prefix = "SPTCollectionCosmos"; +option optimize_for = CODE_SIZE; + +message TrackListMetadata { + optional uint32 unfiltered_length = 1; + optional uint32 length = 2; + optional string offline = 3; + optional uint32 sync_progress = 4; +} diff --git a/protocol/proto/cosmos_get_track_list_request.proto b/protocol/proto/cosmos_get_track_list_request.proto new file mode 100644 index 00000000..95c83410 --- /dev/null +++ b/protocol/proto/cosmos_get_track_list_request.proto @@ -0,0 +1,40 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.track_list_request.proto; + +import "collection/track_collection_state.proto"; +import "played_state/track_played_state.proto"; +import "sync/track_sync_state.proto"; +import "metadata/track_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosTrackList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 add_time = 3; + optional cosmos_util.proto.TrackMetadata track_metadata = 4; + optional cosmos_util.proto.TrackSyncState track_offline_state = 5; + optional cosmos_util.proto.TrackPlayState track_play_state = 6; + optional cosmos_util.proto.TrackCollectionState track_collection_state = 7; + optional string group_label = 8; +} + +message GroupHeader { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 length = 3; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; + optional string offline = 5; + optional uint32 sync_progress = 6; + repeated GroupHeader group_index = 7; +} diff --git a/protocol/proto/cosmos_get_unplayed_episodes_request.proto b/protocol/proto/cosmos_get_unplayed_episodes_request.proto new file mode 100644 index 00000000..09339c78 --- /dev/null +++ b/protocol/proto/cosmos_get_unplayed_episodes_request.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.unplayed_request.proto; + +import "collection/episode_collection_state.proto"; +import "played_state/episode_played_state.proto"; +import "sync/episode_sync_state.proto"; +import "metadata/episode_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosUnplayedEpisodes"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header = 1; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 5; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; +} diff --git a/protocol/proto/cuepoints.proto b/protocol/proto/cuepoints.proto new file mode 100644 index 00000000..16bfd6a9 --- /dev/null +++ b/protocol/proto/cuepoints.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.automix.proto; + +option optimize_for = CODE_SIZE; + +message Cuepoint { + int64 position_ms = 1; + float tempo_bpm = 2; + Origin origin = 3; +} + +message Cuepoints { + Cuepoint fade_in_cuepoint = 1; + Cuepoint fade_out_cuepoint = 2; +} + +enum Origin { + HUMAN = 0; + ML = 1; +} diff --git a/protocol/proto/decorate_request.proto b/protocol/proto/decorate_request.proto new file mode 100644 index 00000000..ff1fa0ed --- /dev/null +++ b/protocol/proto/decorate_request.proto @@ -0,0 +1,54 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.show_cosmos.decorate_request.proto; + +import "metadata/episode_metadata.proto"; +import "metadata/show_metadata.proto"; +import "played_state/episode_played_state.proto"; +import "show_access.proto"; +import "show_episode_state.proto"; +import "show_show_state.proto"; +import "podcast_segments.proto"; +import "podcast_virality.proto"; +import "podcastextensions.proto"; +import "podcast_poll.proto"; +import "podcast_qna.proto"; +import "podcast_ratings.proto"; +import "transcripts.proto"; +import "clips_cover.proto"; + +option objc_class_prefix = "SPTShowCosmosDecorate"; +option optimize_for = CODE_SIZE; + +message Show { + optional cosmos_util.proto.ShowMetadata show_metadata = 1; + optional show_cosmos.proto.ShowCollectionState show_collection_state = 2; + optional show_cosmos.proto.ShowPlayState show_play_state = 3; + optional string link = 4; + optional podcast_paywalls.ShowAccess access_info = 5; + optional ratings.PodcastRating podcast_rating = 6; +} + +message Episode { + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1; + optional show_cosmos.proto.EpisodeCollectionState episode_collection_state = 2; + optional show_cosmos.proto.EpisodeOfflineState episode_offline_state = 3; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 4; + optional string link = 5; + optional podcast_segments.PodcastSegments segments = 6; + optional podcast.extensions.PodcastHtmlDescription html_description = 7; + optional corex.transcripts.metadata.EpisodeTranscript transcripts = 9; + optional podcastvirality.v1.PodcastVirality virality = 10; + optional polls.PodcastPoll podcast_poll = 11; + optional qanda.PodcastQna podcast_qna = 12; + optional clips.ClipsCover clips = 13; + + reserved 8; +} + +message Response { + repeated Show show = 1; + repeated Episode episode = 2; +} diff --git a/protocol/proto/devices.proto b/protocol/proto/devices.proto new file mode 100644 index 00000000..ebfadc1b --- /dev/null +++ b/protocol/proto/devices.proto @@ -0,0 +1,35 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.connectstate.devices; + +option java_package = "com.spotify.common.proto"; + +message DeviceAlias { + uint32 id = 1; + string display_name = 2; + bool is_group = 3; +} + +enum DeviceType { + UNKNOWN = 0; + COMPUTER = 1; + TABLET = 2; + SMARTPHONE = 3; + SPEAKER = 4; + TV = 5; + AVR = 6; + STB = 7; + AUDIO_DONGLE = 8; + GAME_CONSOLE = 9; + CAST_VIDEO = 10; + CAST_AUDIO = 11; + AUTOMOBILE = 12; + SMARTWATCH = 13; + CHROMEBOOK = 14; + UNKNOWN_SPOTIFY = 100; + CAR_THING = 101; + OBSERVER = 102; + HOME_THING = 103; +} diff --git a/protocol/proto/display_segments.proto b/protocol/proto/display_segments.proto new file mode 100644 index 00000000..eb3e02b3 --- /dev/null +++ b/protocol/proto/display_segments.proto @@ -0,0 +1,40 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments.display; + +import "podcast_segments.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "DisplaySegmentsProto"; +option java_package = "com.spotify.podcastsegments.display.proto"; + +message DisplaySegments { + repeated DisplaySegment display_segments = 1; + bool can_upsell = 2; + string album_mosaic_uri = 3; + repeated string artists = 4; + int32 duration_ms = 5; +} + +message DisplaySegment { + string uri = 1; + int32 absolute_start_ms = 2; + int32 absolute_stop_ms = 3; + + Source source = 4; + enum Source { + PLAYBACK = 0; + EMBEDDED = 1; + } + + SegmentType type = 5; + string title = 6; + string subtitle = 7; + string image_url = 8; + string action_url = 9; + bool is_abridged = 10; +} diff --git a/protocol/proto/display_segments_extension.proto b/protocol/proto/display_segments_extension.proto new file mode 100644 index 00000000..04714446 --- /dev/null +++ b/protocol/proto/display_segments_extension.proto @@ -0,0 +1,54 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.displaysegments.v1; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "DisplaySegmentsExtensionProto"; +option java_package = "com.spotify.displaysegments.v1.proto"; + +message DisplaySegmentsExtension { + string episode_uri = 1; + repeated DisplaySegment segments = 2; + int32 duration_ms = 3; + + oneof decoration { + MusicAndTalkDecoration music_and_talk_decoration = 4; + } +} + +message DisplaySegment { + string uri = 1; + SegmentType type = 2; + int32 duration_ms = 3; + int32 seek_start_ms = 4; + int32 seek_stop_ms = 5; + + oneof _title { + string title = 6; + } + + oneof _subtitle { + string subtitle = 7; + } + + oneof _image_url { + string image_url = 8; + } + + oneof _is_preview { + bool is_preview = 9; + } +} + +message MusicAndTalkDecoration { + bool can_upsell = 1; +} + +enum SegmentType { + SEGMENT_TYPE_UNSPECIFIED = 0; + SEGMENT_TYPE_TALK = 1; + SEGMENT_TYPE_MUSIC = 2; +} diff --git a/protocol/proto/entity_extension_data.proto b/protocol/proto/entity_extension_data.proto new file mode 100644 index 00000000..e26d735e --- /dev/null +++ b/protocol/proto/entity_extension_data.proto @@ -0,0 +1,39 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.extendedmetadata; + +import "google/protobuf/any.proto"; + +option cc_enable_arenas = true; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.extendedmetadata.proto"; + +message EntityExtensionDataHeader { + int32 status_code = 1; + string etag = 2; + string locale = 3; + int64 cache_ttl_in_seconds = 4; + int64 offline_ttl_in_seconds = 5; +} + +message EntityExtensionData { + EntityExtensionDataHeader header = 1; + string entity_uri = 2; + google.protobuf.Any extension_data = 3; +} + +message PlainListAssoc { + repeated string entity_uri = 1; +} + +message AssocHeader { + +} + +message Assoc { + AssocHeader header = 1; + PlainListAssoc plain_list = 2; +} diff --git a/protocol/proto/es_add_to_queue_request.proto b/protocol/proto/es_add_to_queue_request.proto new file mode 100644 index 00000000..34997731 --- /dev/null +++ b/protocol/proto/es_add_to_queue_request.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_context_track.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message AddToQueueRequest { + ContextTrack track = 1; + CommandOptions options = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_command_options.proto b/protocol/proto/es_command_options.proto new file mode 100644 index 00000000..0a37e801 --- /dev/null +++ b/protocol/proto/es_command_options.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message CommandOptions { + bool override_restrictions = 1; + bool only_for_local_device = 2; + bool system_initiated = 3; + bytes only_for_playback_id = 4; +} diff --git a/protocol/proto/es_context.proto b/protocol/proto/es_context.proto new file mode 100644 index 00000000..05962fa7 --- /dev/null +++ b/protocol/proto/es_context.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_page.proto"; +import "es_restrictions.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message Context { + repeated ContextPage pages = 1; + map metadata = 2; + string uri = 3; + string url = 4; + bool is_loaded = 5; + Restrictions restrictions = 6; +} diff --git a/protocol/proto/es_context_page.proto b/protocol/proto/es_context_page.proto new file mode 100644 index 00000000..f4cc6930 --- /dev/null +++ b/protocol/proto/es_context_page.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextPage { + repeated ContextTrack tracks = 1; + map metadata = 2; + string page_url = 3; + string next_page_url = 4; + bool is_loaded = 5; +} diff --git a/protocol/proto/es_context_player_error.proto b/protocol/proto/es_context_player_error.proto new file mode 100644 index 00000000..f332fe8a --- /dev/null +++ b/protocol/proto/es_context_player_error.proto @@ -0,0 +1,55 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextPlayerError { + ErrorCode code = 1; + enum ErrorCode { + SUCCESS = 0; + PLAYBACK_STUCK = 1; + PLAYBACK_ERROR = 2; + LICENSE_CHANGE = 3; + PLAY_RESTRICTED = 4; + STOP_RESTRICTED = 5; + UPDATE_RESTRICTED = 6; + PAUSE_RESTRICTED = 7; + RESUME_RESTRICTED = 8; + SKIP_TO_PREV_RESTRICTED = 9; + SKIP_TO_NEXT_RESTRICTED = 10; + SKIP_TO_NON_EXISTENT_TRACK = 11; + SEEK_TO_RESTRICTED = 12; + TOGGLE_REPEAT_CONTEXT_RESTRICTED = 13; + TOGGLE_REPEAT_TRACK_RESTRICTED = 14; + SET_OPTIONS_RESTRICTED = 15; + TOGGLE_SHUFFLE_RESTRICTED = 16; + SET_QUEUE_RESTRICTED = 17; + INTERRUPT_PLAYBACK_RESTRICTED = 18; + ONE_TRACK_UNPLAYABLE = 19; + ONE_TRACK_UNPLAYABLE_AUTO_STOPPED = 20; + ALL_TRACKS_UNPLAYABLE_AUTO_STOPPED = 21; + SKIP_TO_NON_EXISTENT_TRACK_AUTO_STOPPED = 22; + QUEUE_REVISION_MISMATCH = 23; + VIDEO_PLAYBACK_ERROR = 24; + VIDEO_GEOGRAPHICALLY_RESTRICTED = 25; + VIDEO_UNSUPPORTED_PLATFORM_VERSION = 26; + VIDEO_UNSUPPORTED_CLIENT_VERSION = 27; + VIDEO_UNSUPPORTED_KEY_SYSTEM = 28; + VIDEO_MANIFEST_DELETED = 29; + VIDEO_COUNTRY_RESTRICTED = 30; + VIDEO_UNAVAILABLE = 31; + VIDEO_CATALOGUE_RESTRICTED = 32; + INVALID = 33; + TIMEOUT = 34; + PLAYBACK_REPORTING_ERROR = 35; + UNKNOWN = 36; + } + + string message = 2; + map data = 3; +} diff --git a/protocol/proto/es_context_player_options.proto b/protocol/proto/es_context_player_options.proto new file mode 100644 index 00000000..372b53c0 --- /dev/null +++ b/protocol/proto/es_context_player_options.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextPlayerOptions { + bool shuffling_context = 1; + bool repeating_context = 2; + bool repeating_track = 3; +} + +message ContextPlayerOptionOverrides { + OptionalBoolean shuffling_context = 1; + OptionalBoolean repeating_context = 2; + OptionalBoolean repeating_track = 3; +} diff --git a/protocol/proto/es_context_player_state.proto b/protocol/proto/es_context_player_state.proto new file mode 100644 index 00000000..f1626572 --- /dev/null +++ b/protocol/proto/es_context_player_state.proto @@ -0,0 +1,82 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_restrictions.proto"; +import "es_play_origin.proto"; +import "es_optional.proto"; +import "es_provided_track.proto"; +import "es_context_player_options.proto"; +import "es_prepare_play_options.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextIndex { + uint64 page = 1; + uint64 track = 2; +} + +message PlaybackQuality { + BitrateLevel bitrate_level = 1; + enum BitrateLevel { + UNKNOWN = 0; + LOW = 1; + NORMAL = 2; + HIGH = 3; + VERY_HIGH = 4; + HIFI = 5; + } + + BitrateStrategy strategy = 2; + enum BitrateStrategy { + UNKNOWN_STRATEGY = 0; + BEST_MATCHING = 1; + BACKEND_ADVISED = 2; + OFFLINED_FILE = 3; + CACHED_FILE = 4; + LOCAL_FILE = 5; + } + + BitrateLevel target_bitrate_level = 3; + bool target_bitrate_available = 4; + + HiFiStatus hifi_status = 5; + enum HiFiStatus { + NONE = 0; + OFF = 1; + ON = 2; + } +} + +message ContextPlayerState { + uint64 timestamp = 1; + string context_uri = 2; + string context_url = 3; + Restrictions context_restrictions = 4; + PlayOrigin play_origin = 5; + ContextIndex index = 6; + ProvidedTrack track = 7; + bytes playback_id = 8; + PlaybackQuality playback_quality = 9; + OptionalDouble playback_speed = 10; + OptionalInt64 position_as_of_timestamp = 11; + OptionalInt64 duration = 12; + bool is_playing = 13; + bool is_paused = 14; + bool is_buffering = 15; + bool is_system_initiated = 16; + ContextPlayerOptions options = 17; + Restrictions restrictions = 18; + repeated string suppressions = 19; + repeated ProvidedTrack prev_tracks = 20; + repeated ProvidedTrack next_tracks = 21; + map context_metadata = 22; + map page_metadata = 23; + string session_id = 24; + uint64 queue_revision = 25; + PreparePlayOptions.AudioStream audio_stream = 26; +} diff --git a/protocol/proto/es_context_track.proto b/protocol/proto/es_context_track.proto new file mode 100644 index 00000000..cdcbd7c2 --- /dev/null +++ b/protocol/proto/es_context_track.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextTrack { + string uri = 1; + string uid = 2; + map metadata = 3; +} diff --git a/protocol/proto/es_delete_session.proto b/protocol/proto/es_delete_session.proto new file mode 100644 index 00000000..e45893c4 --- /dev/null +++ b/protocol/proto/es_delete_session.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message DeleteSessionRequest { + string session_id = 1; +} + +message DeleteSessionResponse { + +} diff --git a/protocol/proto/es_get_error_request.proto b/protocol/proto/es_get_error_request.proto new file mode 100644 index 00000000..3119beaa --- /dev/null +++ b/protocol/proto/es_get_error_request.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetErrorRequest { + +} diff --git a/protocol/proto/es_get_play_history.proto b/protocol/proto/es_get_play_history.proto new file mode 100644 index 00000000..08bb053c --- /dev/null +++ b/protocol/proto/es_get_play_history.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetPlayHistoryRequest { + +} + +message GetPlayHistoryResponse { + repeated ContextTrack tracks = 1; +} diff --git a/protocol/proto/es_get_position_state.proto b/protocol/proto/es_get_position_state.proto new file mode 100644 index 00000000..6147f0c5 --- /dev/null +++ b/protocol/proto/es_get_position_state.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetPositionStateRequest { + +} + +message GetPositionStateResponse { + Error error = 1; + enum Error { + OK = 0; + NOT_FOUND = 1; + } + + uint64 timestamp = 2; + uint64 position = 3; + double playback_speed = 4; +} diff --git a/protocol/proto/es_get_queue_request.proto b/protocol/proto/es_get_queue_request.proto new file mode 100644 index 00000000..68b6830a --- /dev/null +++ b/protocol/proto/es_get_queue_request.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetQueueRequest { + +} diff --git a/protocol/proto/es_get_state_request.proto b/protocol/proto/es_get_state_request.proto new file mode 100644 index 00000000..d8cd5335 --- /dev/null +++ b/protocol/proto/es_get_state_request.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetStateRequest { + OptionalInt64 prev_tracks_cap = 1; + OptionalInt64 next_tracks_cap = 2; +} diff --git a/protocol/proto/es_ident.proto b/protocol/proto/es_ident.proto new file mode 100644 index 00000000..6c52abc2 --- /dev/null +++ b/protocol/proto/es_ident.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connectivity.pubsub.esperanto.proto; + +option java_package = "com.spotify.connectivity.pubsub.esperanto.proto"; + +message Ident { + string Ident = 1; +} diff --git a/protocol/proto/es_ident_filter.proto b/protocol/proto/es_ident_filter.proto new file mode 100644 index 00000000..19ccee40 --- /dev/null +++ b/protocol/proto/es_ident_filter.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connectivity.pubsub.esperanto.proto; + +option java_package = "com.spotify.connectivity.pubsub.esperanto.proto"; + +message IdentFilter { + string Prefix = 1; +} diff --git a/protocol/proto/es_logging_params.proto b/protocol/proto/es_logging_params.proto new file mode 100644 index 00000000..c508cba2 --- /dev/null +++ b/protocol/proto/es_logging_params.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message LoggingParams { + OptionalInt64 command_initiated_time = 1; + OptionalInt64 command_received_time = 2; + repeated string page_instance_ids = 3; + repeated string interaction_ids = 4; +} diff --git a/protocol/proto/es_optional.proto b/protocol/proto/es_optional.proto new file mode 100644 index 00000000..2ca0b01f --- /dev/null +++ b/protocol/proto/es_optional.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message OptionalInt64 { + int64 value = 1; +} + +message OptionalDouble { + double value = 1; +} + +message OptionalBoolean { + bool value = 1; +} diff --git a/protocol/proto/es_pause.proto b/protocol/proto/es_pause.proto new file mode 100644 index 00000000..56378fb5 --- /dev/null +++ b/protocol/proto/es_pause.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PauseRequest { + CommandOptions options = 1; + LoggingParams logging_params = 2; +} diff --git a/protocol/proto/es_play.proto b/protocol/proto/es_play.proto new file mode 100644 index 00000000..34dca48a --- /dev/null +++ b/protocol/proto/es_play.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_play_options.proto"; +import "es_prepare_play.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PlayRequest { + PreparePlayRequest prepare_play_request = 1; + PlayOptions play_options = 2; + CommandOptions options = 3; + LoggingParams logging_params = 4; +} + +message PlayPreparedRequest { + string session_id = 1; + PlayOptions play_options = 2; + CommandOptions options = 3; + LoggingParams logging_params = 4; +} diff --git a/protocol/proto/es_play_options.proto b/protocol/proto/es_play_options.proto new file mode 100644 index 00000000..f068921b --- /dev/null +++ b/protocol/proto/es_play_options.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PlayOptions { + Reason reason = 1; + enum Reason { + INTERACTIVE = 0; + REMOTE_TRANSFER = 1; + LICENSE_CHANGE = 2; + } + + Operation operation = 2; + enum Operation { + REPLACE = 0; + ENQUEUE = 1; + PUSH = 2; + } + + Trigger trigger = 3; + enum Trigger { + IMMEDIATELY = 0; + ADVANCED_PAST_TRACK = 1; + ADVANCED_PAST_CONTEXT = 2; + } +} diff --git a/protocol/proto/es_play_origin.proto b/protocol/proto/es_play_origin.proto new file mode 100644 index 00000000..62cff8b7 --- /dev/null +++ b/protocol/proto/es_play_origin.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PlayOrigin { + string feature_identifier = 1; + string feature_version = 2; + string view_uri = 3; + string external_referrer = 4; + string referrer_identifier = 5; + string device_identifier = 6; + repeated string feature_classes = 7; +} diff --git a/protocol/proto/es_prefs.proto b/protocol/proto/es_prefs.proto new file mode 100644 index 00000000..f81916ca --- /dev/null +++ b/protocol/proto/es_prefs.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.prefs.esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.prefs.esperanto.proto"; + +service Prefs { + rpc Get(GetParams) returns (PrefValues); + rpc Sub(SubParams) returns (stream PrefValues); + rpc GetAll(GetAllParams) returns (PrefValues); + rpc SubAll(SubAllParams) returns (stream PrefValues); + rpc Set(SetParams) returns (PrefValues); + rpc Create(CreateParams) returns (PrefValues); +} + +message GetParams { + string key = 1; +} + +message SubParams { + string key = 1; +} + +message GetAllParams { + +} + +message SubAllParams { + +} + +message Value { + oneof value { + int64 number = 1; + bool bool = 2; + string string = 3; + } +} + +message SetParams { + map entries = 1; +} + +message CreateParams { + map entries = 1; +} + +message PrefValues { + map entries = 1; +} diff --git a/protocol/proto/es_prepare_play.proto b/protocol/proto/es_prepare_play.proto new file mode 100644 index 00000000..6662eb65 --- /dev/null +++ b/protocol/proto/es_prepare_play.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context.proto"; +import "es_play_origin.proto"; +import "es_prepare_play_options.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PreparePlayRequest { + Context context = 1; + PreparePlayOptions options = 2; + PlayOrigin play_origin = 3; +} diff --git a/protocol/proto/es_prepare_play_options.proto b/protocol/proto/es_prepare_play_options.proto new file mode 100644 index 00000000..b4a4449c --- /dev/null +++ b/protocol/proto/es_prepare_play_options.proto @@ -0,0 +1,40 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_player_options.proto"; +import "es_optional.proto"; +import "es_skip_to_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PreparePlayOptions { + bytes playback_id = 1; + bool always_play_something = 2; + SkipToTrack skip_to = 3; + OptionalInt64 seek_to = 4; + bool initially_paused = 5; + bool system_initiated = 6; + ContextPlayerOptionOverrides player_options_override = 7; + repeated string suppressions = 8; + + PrefetchLevel prefetch_level = 9; + enum PrefetchLevel { + NONE = 0; + MEDIA = 1; + } + + AudioStream audio_stream = 10; + enum AudioStream { + DEFAULT = 0; + ALARM = 1; + } + + string session_id = 11; + string license = 12; + map configuration_override = 13; +} diff --git a/protocol/proto/es_provided_track.proto b/protocol/proto/es_provided_track.proto new file mode 100644 index 00000000..6dcffc0d --- /dev/null +++ b/protocol/proto/es_provided_track.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ProvidedTrack { + ContextTrack context_track = 1; + repeated string removed = 2; + repeated string blocked = 3; + string provider = 4; +} diff --git a/protocol/proto/es_pushed_message.proto b/protocol/proto/es_pushed_message.proto new file mode 100644 index 00000000..dd054f5f --- /dev/null +++ b/protocol/proto/es_pushed_message.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connectivity.pubsub.esperanto.proto; + +import "es_ident.proto"; + +option java_package = "com.spotify.connectivity.pubsub.esperanto.proto"; + +message PushedMessage { + Ident Ident = 1; + repeated string Payloads = 2; + map Attributes = 3; +} diff --git a/protocol/proto/es_queue.proto b/protocol/proto/es_queue.proto new file mode 100644 index 00000000..625b184d --- /dev/null +++ b/protocol/proto/es_queue.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_provided_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message Queue { + uint64 queue_revision = 1; + ProvidedTrack track = 2; + repeated ProvidedTrack next_tracks = 3; + repeated ProvidedTrack prev_tracks = 4; +} diff --git a/protocol/proto/es_remote_config.proto b/protocol/proto/es_remote_config.proto new file mode 100644 index 00000000..fca7f0f9 --- /dev/null +++ b/protocol/proto/es_remote_config.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.remote_config.esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.remoteconfig.esperanto.proto"; + +service RemoteConfig { + rpc lookupBool(LookupRequest) returns (BoolResponse); +} + +message LookupRequest { + string component_id = 1; + string key = 2; +} + +message BoolResponse { + bool value = 1; +} diff --git a/protocol/proto/es_request_info.proto b/protocol/proto/es_request_info.proto new file mode 100644 index 00000000..95b5cb81 --- /dev/null +++ b/protocol/proto/es_request_info.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connectivity.netstat.esperanto.proto; + +option java_package = "com.spotify.connectivity.netstat.esperanto.proto"; + +message RepeatedRequestInfo { + repeated RequestInfo infos = 1; +} + +message RequestInfo { + string uri = 1; + string verb = 2; + string source_identifier = 3; + int32 downloaded = 4; + int32 uploaded = 5; + int32 payload_size = 6; + bool connection_reuse = 7; + int64 event_started = 8; + int64 event_connected = 9; + int64 event_request_sent = 10; + int64 event_first_byte_received = 11; + int64 event_last_byte_received = 12; + int64 event_ended = 13; +} diff --git a/protocol/proto/es_response_with_reasons.proto b/protocol/proto/es_response_with_reasons.proto new file mode 100644 index 00000000..6570a177 --- /dev/null +++ b/protocol/proto/es_response_with_reasons.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ResponseWithReasons { + Error error = 1; + enum Error { + OK = 0; + FORBIDDEN = 1; + NOT_FOUND = 2; + CONFLICT = 3; + } + + string reasons = 2; +} diff --git a/protocol/proto/es_restrictions.proto b/protocol/proto/es_restrictions.proto new file mode 100644 index 00000000..3a5c3a0a --- /dev/null +++ b/protocol/proto/es_restrictions.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message Restrictions { + repeated string disallow_pausing_reasons = 1; + repeated string disallow_resuming_reasons = 2; + repeated string disallow_seeking_reasons = 3; + repeated string disallow_peeking_prev_reasons = 4; + repeated string disallow_peeking_next_reasons = 5; + repeated string disallow_skipping_prev_reasons = 6; + repeated string disallow_skipping_next_reasons = 7; + repeated string disallow_toggling_repeat_context_reasons = 8; + repeated string disallow_toggling_repeat_track_reasons = 9; + repeated string disallow_toggling_shuffle_reasons = 10; + repeated string disallow_set_queue_reasons = 11; + repeated string disallow_interrupting_playback_reasons = 12; + repeated string disallow_transferring_playback_reasons = 13; + repeated string disallow_remote_control_reasons = 14; + repeated string disallow_inserting_into_next_tracks_reasons = 15; + repeated string disallow_inserting_into_context_tracks_reasons = 16; + repeated string disallow_reordering_in_next_tracks_reasons = 17; + repeated string disallow_reordering_in_context_tracks_reasons = 18; + repeated string disallow_removing_from_next_tracks_reasons = 19; + repeated string disallow_removing_from_context_tracks_reasons = 20; + repeated string disallow_updating_context_reasons = 21; +} diff --git a/protocol/proto/es_resume.proto b/protocol/proto/es_resume.proto new file mode 100644 index 00000000..1af5980f --- /dev/null +++ b/protocol/proto/es_resume.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ResumeRequest { + CommandOptions options = 1; + LoggingParams logging_params = 2; +} diff --git a/protocol/proto/es_seek_to.proto b/protocol/proto/es_seek_to.proto new file mode 100644 index 00000000..59073cf9 --- /dev/null +++ b/protocol/proto/es_seek_to.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SeekToRequest { + CommandOptions options = 1; + LoggingParams logging_params = 2; + int64 position = 3; + + Relative relative = 4; + enum Relative { + BEGINNING = 0; + END = 1; + CURRENT = 2; + } +} diff --git a/protocol/proto/es_session_response.proto b/protocol/proto/es_session_response.proto new file mode 100644 index 00000000..692ae30f --- /dev/null +++ b/protocol/proto/es_session_response.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SessionResponse { + string session_id = 1; +} diff --git a/protocol/proto/es_set_options.proto b/protocol/proto/es_set_options.proto new file mode 100644 index 00000000..33faf5f8 --- /dev/null +++ b/protocol/proto/es_set_options.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetOptionsRequest { + OptionalBoolean repeating_track = 1; + OptionalBoolean repeating_context = 2; + OptionalBoolean shuffling_context = 3; + CommandOptions options = 4; + LoggingParams logging_params = 5; +} diff --git a/protocol/proto/es_set_queue_request.proto b/protocol/proto/es_set_queue_request.proto new file mode 100644 index 00000000..83715232 --- /dev/null +++ b/protocol/proto/es_set_queue_request.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_provided_track.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetQueueRequest { + repeated ProvidedTrack next_tracks = 1; + repeated ProvidedTrack prev_tracks = 2; + uint64 queue_revision = 3; + CommandOptions options = 4; + LoggingParams logging_params = 5; +} diff --git a/protocol/proto/es_set_repeating_context.proto b/protocol/proto/es_set_repeating_context.proto new file mode 100644 index 00000000..25667c81 --- /dev/null +++ b/protocol/proto/es_set_repeating_context.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetRepeatingContextRequest { + bool repeating_context = 1; + CommandOptions options = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_set_repeating_track.proto b/protocol/proto/es_set_repeating_track.proto new file mode 100644 index 00000000..01ae3b56 --- /dev/null +++ b/protocol/proto/es_set_repeating_track.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetRepeatingTrackRequest { + bool repeating_track = 1; + CommandOptions options = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_set_shuffling_context.proto b/protocol/proto/es_set_shuffling_context.proto new file mode 100644 index 00000000..6eb779e6 --- /dev/null +++ b/protocol/proto/es_set_shuffling_context.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetShufflingContextRequest { + bool shuffling_context = 1; + CommandOptions options = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_skip_next.proto b/protocol/proto/es_skip_next.proto new file mode 100644 index 00000000..d6b0dc83 --- /dev/null +++ b/protocol/proto/es_skip_next.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SkipNextRequest { + CommandOptions options = 1; + LoggingParams logging_params = 2; + ContextTrack track = 3; +} diff --git a/protocol/proto/es_skip_prev.proto b/protocol/proto/es_skip_prev.proto new file mode 100644 index 00000000..2a6b9a71 --- /dev/null +++ b/protocol/proto/es_skip_prev.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SkipPrevRequest { + CommandOptions options = 1; + bool allow_seeking = 2; + LoggingParams logging_params = 3; + ContextTrack track = 4; +} diff --git a/protocol/proto/es_skip_to_track.proto b/protocol/proto/es_skip_to_track.proto new file mode 100644 index 00000000..ecf0d03f --- /dev/null +++ b/protocol/proto/es_skip_to_track.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SkipToTrack { + string page_url = 1; + OptionalInt64 page_index = 2; + string track_uid = 3; + string track_uri = 4; + OptionalInt64 track_index = 5; +} diff --git a/protocol/proto/es_stop.proto b/protocol/proto/es_stop.proto new file mode 100644 index 00000000..068490e0 --- /dev/null +++ b/protocol/proto/es_stop.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message StopRequest { + CommandOptions options = 1; + + Reason reason = 2; + enum Reason { + INTERACTIVE = 0; + REMOTE_TRANSFER = 1; + SHUTDOWN = 2; + } + + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_storage.proto b/protocol/proto/es_storage.proto new file mode 100644 index 00000000..c20b3be7 --- /dev/null +++ b/protocol/proto/es_storage.proto @@ -0,0 +1,88 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.storage.esperanto.proto; + +import "google/protobuf/empty.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.storage.esperanto.proto"; + +service Storage { + rpc GetCacheSizeLimit(GetCacheSizeLimitParams) returns (CacheSizeLimit); + rpc SetCacheSizeLimit(SetCacheSizeLimitParams) returns (google.protobuf.Empty); + rpc DeleteExpiredItems(DeleteExpiredItemsParams) returns (google.protobuf.Empty); + rpc DeleteUnlockedItems(DeleteUnlockedItemsParams) returns (google.protobuf.Empty); + rpc GetStats(GetStatsParams) returns (Stats); + rpc GetFileRanges(GetFileRangesParams) returns (FileRanges); +} + +message CacheSizeLimit { + int64 size = 1; +} + +message GetCacheSizeLimitParams { + +} + +message SetCacheSizeLimitParams { + CacheSizeLimit limit = 1; +} + +message DeleteExpiredItemsParams { + +} + +message DeleteUnlockedItemsParams { + +} + +message RealmStats { + Realm realm = 1; + int64 size = 2; + int64 num_entries = 3; + int64 num_complete_entries = 4; +} + +message Stats { + string cache_id = 1; + int64 creation_date_sec = 2; + int64 max_cache_size = 3; + int64 current_size = 4; + int64 current_locked_size = 5; + int64 free_space = 6; + int64 total_space = 7; + int64 current_numfiles = 8; + repeated RealmStats realm_stats = 9; +} + +message GetStatsParams { + +} + +message FileRanges { + bool byte_size_known = 1; + uint64 byte_size = 2; + + repeated Range ranges = 3; + message Range { + uint64 from_byte = 1; + uint64 to_byte = 2; + } +} + +message GetFileRangesParams { + Realm realm = 1; + string file_id = 2; +} + +enum Realm { + STREAM = 0; + COVER_ART = 1; + PLAYLIST = 4; + AUDIO_SHOW = 5; + HEAD_FILES = 7; + EXTERNAL_AUDIO_SHOW = 8; + KARAOKE_MASK = 9; +} diff --git a/protocol/proto/es_update.proto b/protocol/proto/es_update.proto new file mode 100644 index 00000000..90734b5d --- /dev/null +++ b/protocol/proto/es_update.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context.proto"; +import "es_context_page.proto"; +import "es_context_track.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message UpdateContextRequest { + string session_id = 1; + Context context = 2; + LoggingParams logging_params = 3; +} + +message UpdateContextPageRequest { + string session_id = 1; + ContextPage context_page = 2; + LoggingParams logging_params = 3; +} + +message UpdateContextTrackRequest { + string session_id = 1; + ContextTrack context_track = 2; + LoggingParams logging_params = 3; +} + +message UpdateViewUriRequest { + string session_id = 1; + string view_uri = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/event_entity.proto b/protocol/proto/event_entity.proto new file mode 100644 index 00000000..06239d59 --- /dev/null +++ b/protocol/proto/event_entity.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventEntity { + uint32 file_format_version = 1; + string event_name = 2; + bytes sequence_id = 3; + uint64 sequence_number = 4; + bytes payload = 5; + string owner = 6; + bool authenticated = 7; + uint64 record_id = 8; +} diff --git a/protocol/proto/explicit_content_pubsub.proto b/protocol/proto/explicit_content_pubsub.proto new file mode 100644 index 00000000..1bb45f91 --- /dev/null +++ b/protocol/proto/explicit_content_pubsub.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.explicit_content.proto; + +option optimize_for = CODE_SIZE; + +message KeyValuePair { + required string key = 1; + required string value = 2; +} + +message UserAttributesUpdate { + repeated KeyValuePair pairs = 1; +} diff --git a/protocol/proto/extended_metadata.proto b/protocol/proto/extended_metadata.proto new file mode 100644 index 00000000..2e38d28d --- /dev/null +++ b/protocol/proto/extended_metadata.proto @@ -0,0 +1,62 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.extendedmetadata; + +import "extension_kind.proto"; +import "entity_extension_data.proto"; + +option cc_enable_arenas = true; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.extendedmetadata.proto"; + +message ExtensionQuery { + ExtensionKind extension_kind = 1; + string etag = 2; +} + +message EntityRequest { + string entity_uri = 1; + repeated ExtensionQuery query = 2; +} + +message BatchedEntityRequestHeader { + string country = 1; + string catalogue = 2; + bytes task_id = 3; +} + +message BatchedEntityRequest { + BatchedEntityRequestHeader header = 1; + repeated EntityRequest entity_request = 2; +} + +message EntityExtensionDataArrayHeader { + int32 provider_error_status = 1; + int64 cache_ttl_in_seconds = 2; + int64 offline_ttl_in_seconds = 3; + ExtensionType extension_type = 4; +} + +message EntityExtensionDataArray { + EntityExtensionDataArrayHeader header = 1; + ExtensionKind extension_kind = 2; + repeated EntityExtensionData extension_data = 3; +} + +message BatchedExtensionResponseHeader { + +} + +message BatchedExtensionResponse { + BatchedExtensionResponseHeader header = 1; + repeated EntityExtensionDataArray extended_metadata = 2; +} + +enum ExtensionType { + UNKNOWN = 0; + GENERIC = 1; + ASSOC = 2; +} diff --git a/protocol/proto/extension_descriptor_type.proto b/protocol/proto/extension_descriptor_type.proto new file mode 100644 index 00000000..2ca05713 --- /dev/null +++ b/protocol/proto/extension_descriptor_type.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.descriptorextension; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.descriptorextension.proto"; + +message ExtensionDescriptor { + string text = 1; + float weight = 2; + repeated ExtensionDescriptorType types = 3; +} + +message ExtensionDescriptorData { + repeated ExtensionDescriptor descriptors = 1; +} + +enum ExtensionDescriptorType { + UNKNOWN = 0; + GENRE = 1; + MOOD = 2; + ACTIVITY = 3; + INSTRUMENT = 4; + TIME = 5; + ERA = 6; + AESTHETIC = 7; +} diff --git a/protocol/proto/extension_kind.proto b/protocol/proto/extension_kind.proto new file mode 100644 index 00000000..02444dea --- /dev/null +++ b/protocol/proto/extension_kind.proto @@ -0,0 +1,54 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.extendedmetadata; + +option objc_class_prefix = "SPTExtendedMetadata"; +option cc_enable_arenas = true; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.extendedmetadata.proto"; + +enum ExtensionKind { + UNKNOWN_EXTENSION = 0; + CANVAZ = 1; + STORYLINES = 2; + PODCAST_TOPICS = 3; + PODCAST_SEGMENTS = 4; + AUDIO_FILES = 5; + TRACK_DESCRIPTOR = 6; + ARTIST_V4 = 8; + ALBUM_V4 = 9; + TRACK_V4 = 10; + SHOW_V4 = 11; + EPISODE_V4 = 12; + PODCAST_HTML_DESCRIPTION = 13; + PODCAST_QUOTES = 14; + USER_PROFILE = 15; + CANVAS_V1 = 16; + SHOW_V4_BASE = 17; + SHOW_V4_EPISODES_ASSOC = 18; + TRACK_DESCRIPTOR_SIGNATURES = 19; + PODCAST_AD_SEGMENTS = 20; + EPISODE_TRANSCRIPTS = 21; + PODCAST_SUBSCRIPTIONS = 22; + EXTRACTED_COLOR = 23; + PODCAST_VIRALITY = 24; + IMAGE_SPARKLES_HACK = 25; + PODCAST_POPULARITY_HACK = 26; + AUTOMIX_MODE = 27; + CUEPOINTS = 28; + PODCAST_POLL = 29; + EPISODE_ACCESS = 30; + SHOW_ACCESS = 31; + PODCAST_QNA = 32; + CLIPS = 33; + PODCAST_CTA_CARDS = 36; + PODCAST_RATING = 37; + DISPLAY_SEGMENTS = 38; + GREENROOM = 39; + USER_CREATED = 40; + CLIENT_CONFIG = 48; + AUDIOBOOK_SPECIFICS = 52; +} diff --git a/protocol/proto/extracted_colors.proto b/protocol/proto/extracted_colors.proto new file mode 100644 index 00000000..999a27ea --- /dev/null +++ b/protocol/proto/extracted_colors.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.context_track_color; + +message ColorResult { + Color color_raw = 1; + Color color_light = 2; + Color color_dark = 3; + Status status = 5; +} + +message Color { + int32 rgb = 1; + bool is_fallback = 2; +} + +enum Status { + OK = 0; + IN_PROGRESS = 1; + INVALID_URL = 2; + INTERNAL = 3; +} diff --git a/protocol/proto/facebook-publish.proto b/protocol/proto/facebook-publish.proto deleted file mode 100644 index 4edef249..00000000 --- a/protocol/proto/facebook-publish.proto +++ /dev/null @@ -1,51 +0,0 @@ -syntax = "proto2"; - -message EventReply { - optional int32 queued = 0x1; - optional RetryInfo retry = 0x2; -} - -message RetryInfo { - optional int32 retry_delay = 0x1; - optional int32 max_retry = 0x2; -} - -message Id { - optional string uri = 0x1; - optional int64 start_time = 0x2; -} - -message Start { - optional int32 length = 0x1; - optional string context_uri = 0x2; - optional int64 end_time = 0x3; -} - -message Seek { - optional int64 end_time = 0x1; -} - -message Pause { - optional int32 seconds_played = 0x1; - optional int64 end_time = 0x2; -} - -message Resume { - optional int32 seconds_played = 0x1; - optional int64 end_time = 0x2; -} - -message End { - optional int32 seconds_played = 0x1; - optional int64 end_time = 0x2; -} - -message Event { - optional Id id = 0x1; - optional Start start = 0x2; - optional Seek seek = 0x3; - optional Pause pause = 0x4; - optional Resume resume = 0x5; - optional End end = 0x6; -} - diff --git a/protocol/proto/facebook.proto b/protocol/proto/facebook.proto deleted file mode 100644 index 8227c5a1..00000000 --- a/protocol/proto/facebook.proto +++ /dev/null @@ -1,183 +0,0 @@ -syntax = "proto2"; - -message Credential { - optional string facebook_uid = 0x1; - optional string access_token = 0x2; -} - -message EnableRequest { - optional Credential credential = 0x1; -} - -message EnableReply { - optional Credential credential = 0x1; -} - -message DisableRequest { - optional Credential credential = 0x1; -} - -message RevokeRequest { - optional Credential credential = 0x1; -} - -message InspectCredentialRequest { - optional Credential credential = 0x1; -} - -message InspectCredentialReply { - optional Credential alternative_credential = 0x1; - optional bool app_user = 0x2; - optional bool permanent_error = 0x3; - optional bool transient_error = 0x4; -} - -message UserState { - optional Credential credential = 0x1; -} - -message UpdateUserStateRequest { - optional Credential credential = 0x1; -} - -message OpenGraphError { - repeated string permanent = 0x1; - repeated string invalid_token = 0x2; - repeated string retries = 0x3; -} - -message OpenGraphScrobble { - optional int32 create_delay = 0x1; -} - -message OpenGraphConfig { - optional OpenGraphError error = 0x1; - optional OpenGraphScrobble scrobble = 0x2; -} - -message AuthConfig { - optional string url = 0x1; - repeated string permissions = 0x2; - repeated string blacklist = 0x3; - repeated string whitelist = 0x4; - repeated string cancel = 0x5; -} - -message ConfigReply { - optional string domain = 0x1; - optional string app_id = 0x2; - optional string app_namespace = 0x3; - optional AuthConfig auth = 0x4; - optional OpenGraphConfig og = 0x5; -} - -message UserFields { - optional bool app_user = 0x1; - optional bool display_name = 0x2; - optional bool first_name = 0x3; - optional bool middle_name = 0x4; - optional bool last_name = 0x5; - optional bool picture_large = 0x6; - optional bool picture_square = 0x7; - optional bool gender = 0x8; - optional bool email = 0x9; -} - -message UserOptions { - optional bool cache_is_king = 0x1; -} - -message UserRequest { - optional UserOptions options = 0x1; - optional UserFields fields = 0x2; -} - -message User { - optional string spotify_username = 0x1; - optional string facebook_uid = 0x2; - optional bool app_user = 0x3; - optional string display_name = 0x4; - optional string first_name = 0x5; - optional string middle_name = 0x6; - optional string last_name = 0x7; - optional string picture_large = 0x8; - optional string picture_square = 0x9; - optional string gender = 0xa; - optional string email = 0xb; -} - -message FriendsFields { - optional bool app_user = 0x1; - optional bool display_name = 0x2; - optional bool picture_large = 0x6; -} - -message FriendsOptions { - optional int32 limit = 0x1; - optional int32 offset = 0x2; - optional bool cache_is_king = 0x3; - optional bool app_friends = 0x4; - optional bool non_app_friends = 0x5; -} - -message FriendsRequest { - optional FriendsOptions options = 0x1; - optional FriendsFields fields = 0x2; -} - -message FriendsReply { - repeated User friends = 0x1; - optional bool more = 0x2; -} - -message ShareRequest { - optional Credential credential = 0x1; - optional string uri = 0x2; - optional string message_text = 0x3; -} - -message ShareReply { - optional string post_id = 0x1; -} - -message InboxRequest { - optional Credential credential = 0x1; - repeated string facebook_uids = 0x3; - optional string message_text = 0x4; - optional string message_link = 0x5; -} - -message InboxReply { - optional string message_id = 0x1; - optional string thread_id = 0x2; -} - -message PermissionsOptions { - optional bool cache_is_king = 0x1; -} - -message PermissionsRequest { - optional Credential credential = 0x1; - optional PermissionsOptions options = 0x2; -} - -message PermissionsReply { - repeated string permissions = 0x1; -} - -message GrantPermissionsRequest { - optional Credential credential = 0x1; - repeated string permissions = 0x2; -} - -message GrantPermissionsReply { - repeated string granted = 0x1; - repeated string failed = 0x2; -} - -message TransferRequest { - optional Credential credential = 0x1; - optional string source_username = 0x2; - optional string target_username = 0x3; -} - diff --git a/protocol/proto/follow_request.proto b/protocol/proto/follow_request.proto new file mode 100644 index 00000000..5a026895 --- /dev/null +++ b/protocol/proto/follow_request.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.socialgraph_esperanto.proto; + +import "socialgraph_response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.socialgraph.esperanto.proto"; + +message FollowRequest { + repeated string username = 1; + bool follow = 2; +} + +message FollowResponse { + ResponseStatus status = 1; +} diff --git a/protocol/proto/followed_users_request.proto b/protocol/proto/followed_users_request.proto new file mode 100644 index 00000000..afb71f43 --- /dev/null +++ b/protocol/proto/followed_users_request.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.socialgraph_esperanto.proto; + +import "socialgraph_response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.socialgraph.esperanto.proto"; + +message FollowedUsersRequest { + bool force_reload = 1; +} + +message FollowedUsersResponse { + ResponseStatus status = 1; + repeated string users = 2; +} diff --git a/protocol/proto/frecency.proto b/protocol/proto/frecency.proto new file mode 100644 index 00000000..89c6c7f6 --- /dev/null +++ b/protocol/proto/frecency.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.frecency.v1; + +import "google/protobuf/timestamp.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "FrecencyProto"; +option java_package = "com.spotify.frecency.v1"; + +message FrecencyResponse { + repeated PlayContext play_contexts = 1; +} + +message PlayContext { + string uri = 1; + Frecency frecency = 2; +} + +message Frecency { + double ln_frecency = 1; + int32 event_count = 2; + google.protobuf.Timestamp last_event_time = 3; +} diff --git a/protocol/proto/frecency_storage.proto b/protocol/proto/frecency_storage.proto new file mode 100644 index 00000000..9e32269f --- /dev/null +++ b/protocol/proto/frecency_storage.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.frecency.proto.storage; + +option cc_enable_arenas = true; +option optimize_for = CODE_SIZE; + +message Frecency { + optional double ln_frecency = 1; + optional uint64 event_count = 2; + optional uint32 event_kind = 3; + optional uint64 last_event_time = 4; +} + +message ContextFrecencyInfo { + optional string context_uri = 1; + repeated Frecency context_frecencies = 2; +} + +message ContextFrecencyFile { + repeated ContextFrecencyInfo contexts = 1; + optional uint64 frecency_version = 2; +} diff --git a/protocol/proto/gabito.proto b/protocol/proto/gabito.proto new file mode 100644 index 00000000..b47f4fdd --- /dev/null +++ b/protocol/proto/gabito.proto @@ -0,0 +1,36 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventEnvelope { + string event_name = 2; + + repeated EventFragment event_fragment = 3; + message EventFragment { + string name = 1; + bytes data = 2; + } + + bytes sequence_id = 4; + int64 sequence_number = 5; + + reserved 1; +} + +message PublishEventsRequest { + repeated EventEnvelope event = 1; + bool suppress_persist = 2; +} + +message PublishEventsResponse { + repeated EventError error = 1; + message EventError { + int32 index = 1; + bool transient = 2; + int32 reason = 3; + } +} diff --git a/protocol/proto/global_node.proto b/protocol/proto/global_node.proto new file mode 100644 index 00000000..cd6f1b6c --- /dev/null +++ b/protocol/proto/global_node.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_player_options.proto"; +import "player_license.proto"; + +option optimize_for = CODE_SIZE; + +message GlobalNode { + optional ContextPlayerOptions options = 1; + optional PlayerLicense license = 2; + map configuration = 3; +} diff --git a/protocol/proto/google/protobuf/any.proto b/protocol/proto/google/protobuf/any.proto new file mode 100644 index 00000000..bb7f136c --- /dev/null +++ b/protocol/proto/google/protobuf/any.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option go_package = "google.golang.org/protobuf/types/known/anypb"; +option java_multiple_files = true; +option java_outer_classname = "AnyProto"; +option java_package = "com.google.protobuf"; + +message Any { + string type_url = 1; + bytes value = 2; +} diff --git a/protocol/proto/google/protobuf/descriptor.proto b/protocol/proto/google/protobuf/descriptor.proto new file mode 100644 index 00000000..884a5151 --- /dev/null +++ b/protocol/proto/google/protobuf/descriptor.proto @@ -0,0 +1,301 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.Reflection"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/descriptorpb"; +option optimize_for = SPEED; +option java_outer_classname = "DescriptorProtos"; +option java_package = "com.google.protobuf"; + +message FileDescriptorSet { + repeated FileDescriptorProto file = 1; +} + +message FileDescriptorProto { + optional string name = 1; + optional string package = 2; + repeated string dependency = 3; + repeated int32 public_dependency = 10; + repeated int32 weak_dependency = 11; + repeated DescriptorProto message_type = 4; + repeated EnumDescriptorProto enum_type = 5; + repeated ServiceDescriptorProto service = 6; + repeated FieldDescriptorProto extension = 7; + optional FileOptions options = 8; + optional SourceCodeInfo source_code_info = 9; + optional string syntax = 12; +} + +message DescriptorProto { + optional string name = 1; + repeated FieldDescriptorProto field = 2; + repeated FieldDescriptorProto extension = 6; + repeated DescriptorProto nested_type = 3; + repeated EnumDescriptorProto enum_type = 4; + + repeated ExtensionRange extension_range = 5; + message ExtensionRange { + optional int32 start = 1; + optional int32 end = 2; + optional ExtensionRangeOptions options = 3; + } + + repeated OneofDescriptorProto oneof_decl = 8; + optional MessageOptions options = 7; + + repeated ReservedRange reserved_range = 9; + message ReservedRange { + optional int32 start = 1; + optional int32 end = 2; + } + + repeated string reserved_name = 10; +} + +message ExtensionRangeOptions { + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; +} + +message FieldDescriptorProto { + optional string name = 1; + optional int32 number = 3; + + optional Label label = 4; + enum Label { + LABEL_OPTIONAL = 1; + LABEL_REQUIRED = 2; + LABEL_REPEATED = 3; + } + + optional Type type = 5; + enum Type { + TYPE_DOUBLE = 1; + TYPE_FLOAT = 2; + TYPE_INT64 = 3; + TYPE_UINT64 = 4; + TYPE_INT32 = 5; + TYPE_FIXED64 = 6; + TYPE_FIXED32 = 7; + TYPE_BOOL = 8; + TYPE_STRING = 9; + TYPE_GROUP = 10; + TYPE_MESSAGE = 11; + TYPE_BYTES = 12; + TYPE_UINT32 = 13; + TYPE_ENUM = 14; + TYPE_SFIXED32 = 15; + TYPE_SFIXED64 = 16; + TYPE_SINT32 = 17; + TYPE_SINT64 = 18; + } + + optional string type_name = 6; + optional string extendee = 2; + optional string default_value = 7; + optional int32 oneof_index = 9; + optional string json_name = 10; + optional FieldOptions options = 8; + optional bool proto3_optional = 17; +} + +message OneofDescriptorProto { + optional string name = 1; + optional OneofOptions options = 2; +} + +message EnumDescriptorProto { + optional string name = 1; + repeated EnumValueDescriptorProto value = 2; + optional EnumOptions options = 3; + + repeated EnumReservedRange reserved_range = 4; + message EnumReservedRange { + optional int32 start = 1; + optional int32 end = 2; + } + + repeated string reserved_name = 5; +} + +message EnumValueDescriptorProto { + optional string name = 1; + optional int32 number = 2; + optional EnumValueOptions options = 3; +} + +message ServiceDescriptorProto { + optional string name = 1; + repeated MethodDescriptorProto method = 2; + optional ServiceOptions options = 3; +} + +message MethodDescriptorProto { + optional string name = 1; + optional string input_type = 2; + optional string output_type = 3; + optional MethodOptions options = 4; + optional bool client_streaming = 5 [default = false]; + optional bool server_streaming = 6 [default = false]; +} + +message FileOptions { + optional string java_package = 1; + optional string java_outer_classname = 8; + optional bool java_multiple_files = 10 [default = false]; + optional bool java_generate_equals_and_hash = 20 [deprecated = true]; + optional bool java_string_check_utf8 = 27 [default = false]; + + optional OptimizeMode optimize_for = 9 [default = SPEED]; + enum OptimizeMode { + SPEED = 1; + CODE_SIZE = 2; + LITE_RUNTIME = 3; + } + + optional string go_package = 11; + optional bool cc_generic_services = 16 [default = false]; + optional bool java_generic_services = 17 [default = false]; + optional bool py_generic_services = 18 [default = false]; + optional bool php_generic_services = 42 [default = false]; + optional bool deprecated = 23 [default = false]; + optional bool cc_enable_arenas = 31 [default = true]; + optional string objc_class_prefix = 36; + optional string csharp_namespace = 37; + optional string swift_prefix = 39; + optional string php_class_prefix = 40; + optional string php_namespace = 41; + optional string php_metadata_namespace = 44; + optional string ruby_package = 45; + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; + + reserved 38; +} + +message MessageOptions { + optional bool message_set_wire_format = 1 [default = false]; + optional bool no_standard_descriptor_accessor = 2 [default = false]; + optional bool deprecated = 3 [default = false]; + optional bool map_entry = 7; + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; + + reserved 4, 5, 6, 8, 9; +} + +message FieldOptions { + optional CType ctype = 1 [default = STRING]; + enum CType { + STRING = 0; + CORD = 1; + STRING_PIECE = 2; + } + + optional bool packed = 2; + + optional JSType jstype = 6 [default = JS_NORMAL]; + enum JSType { + JS_NORMAL = 0; + JS_STRING = 1; + JS_NUMBER = 2; + } + + optional bool lazy = 5 [default = false]; + optional bool deprecated = 3 [default = false]; + optional bool weak = 10 [default = false]; + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; + + reserved 4; +} + +message OneofOptions { + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; +} + +message EnumOptions { + optional bool allow_alias = 2; + optional bool deprecated = 3 [default = false]; + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; + + reserved 5; +} + +message EnumValueOptions { + optional bool deprecated = 1 [default = false]; + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; +} + +message ServiceOptions { + optional bool deprecated = 33 [default = false]; + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; +} + +message MethodOptions { + optional bool deprecated = 33 [default = false]; + + optional IdempotencyLevel idempotency_level = 34 [default = IDEMPOTENCY_UNKNOWN]; + enum IdempotencyLevel { + IDEMPOTENCY_UNKNOWN = 0; + NO_SIDE_EFFECTS = 1; + IDEMPOTENT = 2; + } + + repeated UninterpretedOption uninterpreted_option = 999; + + extensions 1000 to max; +} + +message UninterpretedOption { + repeated NamePart name = 2; + message NamePart { + required string name_part = 1; + required bool is_extension = 2; + } + + optional string identifier_value = 3; + optional uint64 positive_int_value = 4; + optional int64 negative_int_value = 5; + optional double double_value = 6; + optional bytes string_value = 7; + optional string aggregate_value = 8; +} + +message SourceCodeInfo { + repeated Location location = 1; + message Location { + repeated int32 path = 1 [packed = true]; + repeated int32 span = 2 [packed = true]; + optional string leading_comments = 3; + optional string trailing_comments = 4; + repeated string leading_detached_comments = 6; + } +} + +message GeneratedCodeInfo { + repeated Annotation annotation = 1; + message Annotation { + repeated int32 path = 1 [packed = true]; + optional string source_file = 2; + optional int32 begin = 3; + optional int32 end = 4; + } +} diff --git a/protocol/proto/google/protobuf/duration.proto b/protocol/proto/google/protobuf/duration.proto new file mode 100644 index 00000000..f7d01301 --- /dev/null +++ b/protocol/proto/google/protobuf/duration.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/durationpb"; +option java_multiple_files = true; +option java_outer_classname = "DurationProto"; +option java_package = "com.google.protobuf"; + +message Duration { + int64 seconds = 1; + int32 nanos = 2; +} diff --git a/protocol/proto/google/protobuf/empty.proto b/protocol/proto/google/protobuf/empty.proto new file mode 100644 index 00000000..28c4d77b --- /dev/null +++ b/protocol/proto/google/protobuf/empty.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/emptypb"; +option java_multiple_files = true; +option java_outer_classname = "EmptyProto"; +option java_package = "com.google.protobuf"; + +message Empty { + +} diff --git a/protocol/proto/google/protobuf/field_mask.proto b/protocol/proto/google/protobuf/field_mask.proto new file mode 100644 index 00000000..3ae48712 --- /dev/null +++ b/protocol/proto/google/protobuf/field_mask.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/fieldmaskpb"; +option java_multiple_files = true; +option java_outer_classname = "FieldMaskProto"; +option java_package = "com.google.protobuf"; + +message FieldMask { + repeated string paths = 1; +} diff --git a/protocol/proto/google/protobuf/source_context.proto b/protocol/proto/google/protobuf/source_context.proto new file mode 100644 index 00000000..8449fd4b --- /dev/null +++ b/protocol/proto/google/protobuf/source_context.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option go_package = "google.golang.org/protobuf/types/known/sourcecontextpb"; +option java_multiple_files = true; +option java_outer_classname = "SourceContextProto"; +option java_package = "com.google.protobuf"; + +message SourceContext { + string file_name = 1; +} diff --git a/protocol/proto/google/protobuf/timestamp.proto b/protocol/proto/google/protobuf/timestamp.proto new file mode 100644 index 00000000..e402c47a --- /dev/null +++ b/protocol/proto/google/protobuf/timestamp.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/timestamppb"; +option java_multiple_files = true; +option java_outer_classname = "TimestampProto"; +option java_package = "com.google.protobuf"; + +message Timestamp { + int64 seconds = 1; + int32 nanos = 2; +} diff --git a/protocol/proto/google/protobuf/type.proto b/protocol/proto/google/protobuf/type.proto new file mode 100644 index 00000000..38d7f2d1 --- /dev/null +++ b/protocol/proto/google/protobuf/type.proto @@ -0,0 +1,91 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package google.protobuf; + +import "google/protobuf/any.proto"; +import "google/protobuf/source_context.proto"; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/typepb"; +option java_multiple_files = true; +option java_outer_classname = "TypeProto"; +option java_package = "com.google.protobuf"; + +message Type { + string name = 1; + repeated Field fields = 2; + repeated string oneofs = 3; + repeated Option options = 4; + SourceContext source_context = 5; + Syntax syntax = 6; +} + +message Field { + Kind kind = 1; + enum Kind { + TYPE_UNKNOWN = 0; + TYPE_DOUBLE = 1; + TYPE_FLOAT = 2; + TYPE_INT64 = 3; + TYPE_UINT64 = 4; + TYPE_INT32 = 5; + TYPE_FIXED64 = 6; + TYPE_FIXED32 = 7; + TYPE_BOOL = 8; + TYPE_STRING = 9; + TYPE_GROUP = 10; + TYPE_MESSAGE = 11; + TYPE_BYTES = 12; + TYPE_UINT32 = 13; + TYPE_ENUM = 14; + TYPE_SFIXED32 = 15; + TYPE_SFIXED64 = 16; + TYPE_SINT32 = 17; + TYPE_SINT64 = 18; + } + + Cardinality cardinality = 2; + enum Cardinality { + CARDINALITY_UNKNOWN = 0; + CARDINALITY_OPTIONAL = 1; + CARDINALITY_REQUIRED = 2; + CARDINALITY_REPEATED = 3; + } + + int32 number = 3; + string name = 4; + string type_url = 6; + int32 oneof_index = 7; + bool packed = 8; + repeated Option options = 9; + string json_name = 10; + string default_value = 11; +} + +message Enum { + string name = 1; + repeated EnumValue enumvalue = 2; + repeated Option options = 3; + SourceContext source_context = 4; + Syntax syntax = 5; +} + +message EnumValue { + string name = 1; + int32 number = 2; + repeated Option options = 3; +} + +message Option { + string name = 1; + Any value = 2; +} + +enum Syntax { + SYNTAX_PROTO2 = 0; + SYNTAX_PROTO3 = 1; +} diff --git a/protocol/proto/google/protobuf/wrappers.proto b/protocol/proto/google/protobuf/wrappers.proto new file mode 100644 index 00000000..10e94ee0 --- /dev/null +++ b/protocol/proto/google/protobuf/wrappers.proto @@ -0,0 +1,49 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/wrapperspb"; +option java_multiple_files = true; +option java_outer_classname = "WrappersProto"; +option java_package = "com.google.protobuf"; + +message DoubleValue { + double value = 1; +} + +message FloatValue { + float value = 1; +} + +message Int64Value { + int64 value = 1; +} + +message UInt64Value { + uint64 value = 1; +} + +message Int32Value { + int32 value = 1; +} + +message UInt32Value { + uint32 value = 1; +} + +message BoolValue { + bool value = 1; +} + +message StringValue { + string value = 1; +} + +message BytesValue { + bytes value = 1; +} diff --git a/protocol/proto/greenroom_extension.proto b/protocol/proto/greenroom_extension.proto new file mode 100644 index 00000000..4fc8dbe3 --- /dev/null +++ b/protocol/proto/greenroom_extension.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.greenroom.api.extendedmetadata.v1; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "GreenroomMetadataProto"; +option java_package = "com.spotify.greenroom.api.extendedmetadata.v1.proto"; + +message GreenroomSection { + repeated GreenroomItem items = 1; +} + +message GreenroomItem { + string title = 1; + string description = 2; + repeated GreenroomHost hosts = 3; + int64 start_timestamp = 4; + string deeplink_url = 5; + bool live = 6; +} + +message GreenroomHost { + string name = 1; + string image_url = 2; +} diff --git a/protocol/proto/identity.proto b/protocol/proto/identity.proto new file mode 100644 index 00000000..efd8b9e1 --- /dev/null +++ b/protocol/proto/identity.proto @@ -0,0 +1,37 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.identity.v3; + +import "google/protobuf/field_mask.proto"; +import "google/protobuf/wrappers.proto"; + +option optimize_for = CODE_SIZE; +option java_outer_classname = "IdentityV3"; +option java_package = "com.spotify.identity.proto.v3"; + +message Image { + int32 max_width = 1; + int32 max_height = 2; + string url = 3; +} + +message UserProfile { + google.protobuf.StringValue username = 1; + google.protobuf.StringValue name = 2; + repeated Image images = 3; + google.protobuf.BoolValue verified = 4; + google.protobuf.BoolValue edit_profile_disabled = 5; + google.protobuf.BoolValue report_abuse_disabled = 6; + google.protobuf.BoolValue abuse_reported_name = 7; + google.protobuf.BoolValue abuse_reported_image = 8; + google.protobuf.BoolValue has_spotify_name = 9; + google.protobuf.BoolValue has_spotify_image = 10; + google.protobuf.Int32Value color = 11; +} + +message UserProfileUpdateRequest { + google.protobuf.FieldMask mask = 1; + UserProfile user_profile = 2; +} diff --git a/protocol/proto/image-resolve.proto b/protocol/proto/image-resolve.proto new file mode 100644 index 00000000..d8befe97 --- /dev/null +++ b/protocol/proto/image-resolve.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.imageresolve.proto; + +option java_multiple_files = true; +option java_outer_classname = "ImageResolveProtos"; +option java_package = "com.spotify.imageresolve.proto"; + +message Collection { + bytes id = 1; + + repeated Projection projections = 2; + message Projection { + bytes id = 2; + int32 metadata_index = 3; + int32 url_template_index = 4; + } +} + +message ProjectionMetadata { + int32 width = 2; + int32 height = 3; + bool fetch_online = 4; + bool download_for_offline = 5; +} + +message ProjectionMap { + repeated string url_templates = 1; + repeated ProjectionMetadata projection_metas = 2; + repeated Collection collections = 3; +} diff --git a/protocol/proto/installation_data.proto b/protocol/proto/installation_data.proto new file mode 100644 index 00000000..083fe466 --- /dev/null +++ b/protocol/proto/installation_data.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message InstallationEntity { + int32 file_format_version = 1; + bytes encrypted_part = 2; +} + +message InstallationData { + string installation_id = 1; + string last_seen_device_id = 2; + int64 monotonic_clock_id = 3; +} diff --git a/protocol/proto/instrumentation_params.proto b/protocol/proto/instrumentation_params.proto new file mode 100644 index 00000000..b8e44f4a --- /dev/null +++ b/protocol/proto/instrumentation_params.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +option optimize_for = CODE_SIZE; + +message InstrumentationParams { + repeated string interaction_ids = 6; + repeated string page_instance_ids = 7; +} diff --git a/protocol/proto/keyexchange.proto b/protocol/proto/keyexchange.proto index 0907c912..840f5524 100644 --- a/protocol/proto/keyexchange.proto +++ b/protocol/proto/keyexchange.proto @@ -57,6 +57,23 @@ enum Platform { PLATFORM_ONKYO_ARM = 0x15; PLATFORM_QNXNTO_ARM = 0x16; PLATFORM_BCO_ARM = 0x17; + PLATFORM_WEBPLAYER = 0x18; + PLATFORM_WP8_ARM = 0x19; + PLATFORM_WP8_X86 = 0x1a; + PLATFORM_WINRT_ARM = 0x1b; + PLATFORM_WINRT_X86 = 0x1c; + PLATFORM_WINRT_X86_64 = 0x1d; + PLATFORM_FRONTIER = 0x1e; + PLATFORM_AMIGA_PPC = 0x1f; + PLATFORM_NANRADIO_NRX901 = 0x20; + PLATFORM_HARMAN_ARM = 0x21; + PLATFORM_SONY_PS3 = 0x22; + PLATFORM_SONY_PS4 = 0x23; + PLATFORM_IPHONE_ARM64 = 0x24; + PLATFORM_RTEMS_PPC = 0x25; + PLATFORM_GENERIC_PARTNER = 0x26; + PLATFORM_WIN32_X86_64 = 0x27; + PLATFORM_WATCHOS = 0x28; } enum Fingerprint { diff --git a/protocol/proto/lfs_secret_provider.proto b/protocol/proto/lfs_secret_provider.proto new file mode 100644 index 00000000..0862181e --- /dev/null +++ b/protocol/proto/lfs_secret_provider.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.lfssecretprovider.proto; + +option optimize_for = CODE_SIZE; + +message GetSecretResponse { + bytes secret = 1; +} diff --git a/protocol/proto/liked_songs_tags_sync_state.proto b/protocol/proto/liked_songs_tags_sync_state.proto new file mode 100644 index 00000000..634f9d03 --- /dev/null +++ b/protocol/proto/liked_songs_tags_sync_state.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.collection.proto; + +option optimize_for = CODE_SIZE; + +message TagsSyncState { + string uri = 1; + bool sync_is_complete = 2; +} diff --git a/protocol/proto/listen_later_cosmos_response.proto b/protocol/proto/listen_later_cosmos_response.proto new file mode 100644 index 00000000..f71c577c --- /dev/null +++ b/protocol/proto/listen_later_cosmos_response.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.listen_later_cosmos.proto; + +import "collection/episode_collection_state.proto"; +import "metadata/episode_metadata.proto"; +import "played_state/episode_played_state.proto"; +import "sync/episode_sync_state.proto"; + +option optimize_for = CODE_SIZE; + +message Episode { + optional string header = 1; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_played_state = 5; +} + +message EpisodesResponse { + optional uint32 unfiltered_length = 1; + optional uint32 unranged_length = 2; + repeated Episode episode = 3; + optional string offline_availability = 5; + optional uint32 offline_progress = 6; +} diff --git a/protocol/proto/local_bans_storage.proto b/protocol/proto/local_bans_storage.proto new file mode 100644 index 00000000..388f05b5 --- /dev/null +++ b/protocol/proto/local_bans_storage.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.collection.proto.storage; + +option optimize_for = CODE_SIZE; + +message BanItem { + required string item_uri = 1; + required string context_uri = 2; + required int64 timestamp = 3; +} + +message Bans { + repeated BanItem items = 1; +} diff --git a/protocol/proto/local_sync_cosmos.proto b/protocol/proto/local_sync_cosmos.proto new file mode 100644 index 00000000..cf6187f7 --- /dev/null +++ b/protocol/proto/local_sync_cosmos.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.local_sync_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message GetDevicesResponse { + repeated Device devices = 1; + message Device { + string name = 1; + string id = 2; + string endpoint = 3; + } +} diff --git a/protocol/proto/local_sync_state.proto b/protocol/proto/local_sync_state.proto new file mode 100644 index 00000000..630f1843 --- /dev/null +++ b/protocol/proto/local_sync_state.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.local_sync_state.proto; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.local_sync_state.proto"; + +message State { + string safe_secret = 1; +} diff --git a/protocol/proto/logging_params.proto b/protocol/proto/logging_params.proto new file mode 100644 index 00000000..1f11809d --- /dev/null +++ b/protocol/proto/logging_params.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message LoggingParams { + optional int64 command_initiated_time = 1; + optional int64 command_received_time = 2; + repeated string page_instance_ids = 3; + repeated string interaction_ids = 4; +} diff --git a/protocol/proto/mdata.proto b/protocol/proto/mdata.proto new file mode 100644 index 00000000..29faad9c --- /dev/null +++ b/protocol/proto/mdata.proto @@ -0,0 +1,43 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.mdata.proto; + +import "extension_kind.proto"; +import "google/protobuf/any.proto"; + +option cc_enable_arenas = true; +option optimize_for = CODE_SIZE; + +message LocalExtensionQuery { + extendedmetadata.ExtensionKind extension_kind = 1; + repeated string entity_uri = 2; +} + +message LocalBatchedEntityRequest { + repeated LocalExtensionQuery extension_query = 1; +} + +message LocalBatchedExtensionResponse { + repeated Extension extension = 1; + message Extension { + extendedmetadata.ExtensionKind extension_kind = 1; + repeated EntityExtension entity_extension = 2; + } + + message ExtensionHeader { + bool cache_valid = 1; + bool offline_valid = 2; + int32 status_code = 3; + bool is_empty = 4; + int64 cache_expiry_timestamp = 5; + int64 offline_expiry_timestamp = 6; + } + + message EntityExtension { + string entity_uri = 1; + ExtensionHeader header = 2; + google.protobuf.Any extension_data = 3; + } +} diff --git a/protocol/proto/mdata_cosmos.proto b/protocol/proto/mdata_cosmos.proto new file mode 100644 index 00000000..3c67357c --- /dev/null +++ b/protocol/proto/mdata_cosmos.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.mdata_cosmos.proto; + +import "extension_kind.proto"; + +option cc_enable_arenas = true; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.mdata.cosmos.proto"; + +message InvalidateCacheRequest { + extendedmetadata.ExtensionKind extension_kind = 1; + repeated string entity_uri = 2; +} + +message InvalidateCacheResponse { + +} diff --git a/protocol/proto/mdata_storage.proto b/protocol/proto/mdata_storage.proto new file mode 100644 index 00000000..8703fe54 --- /dev/null +++ b/protocol/proto/mdata_storage.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.mdata.proto.storage; + +import "extension_kind.proto"; +import "google/protobuf/any.proto"; + +option cc_enable_arenas = true; +option optimize_for = CODE_SIZE; + +message CacheEntry { + extendedmetadata.ExtensionKind kind = 1; + google.protobuf.Any extension_data = 2; +} + +message CacheInfo { + int32 status_code = 1; + bool is_empty = 2; + uint64 cache_expiry = 3; + uint64 offline_expiry = 4; + string etag = 5; + fixed64 cache_checksum_lo = 6; + fixed64 cache_checksum_hi = 7; +} + +message OfflineLock { + uint64 lock_expiry = 1; +} + +message AudioFiles { + string file_id = 1; +} + +message TrackDescriptor { + int32 track_id = 1; +} diff --git a/protocol/proto/media_format.proto b/protocol/proto/media_format.proto new file mode 100644 index 00000000..c54f6323 --- /dev/null +++ b/protocol/proto/media_format.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum MediaFormat { + FORMAT_UNKNOWN = 0; + FORMAT_OGG_VORBIS_96 = 1; + FORMAT_OGG_VORBIS_160 = 2; + FORMAT_OGG_VORBIS_320 = 3; + FORMAT_MP3_256 = 4; + FORMAT_MP3_320 = 5; + FORMAT_MP3_160 = 6; + FORMAT_MP3_96 = 7; + FORMAT_MP3_160_ENCRYPTED = 8; + FORMAT_AAC_24 = 9; + FORMAT_AAC_48 = 10; + FORMAT_MP4_128 = 11; + FORMAT_MP4_128_DUAL = 12; + FORMAT_MP4_128_CBCS = 13; + FORMAT_MP4_256 = 14; + FORMAT_MP4_256_DUAL = 15; + FORMAT_MP4_256_CBCS = 16; + FORMAT_FLAC_FLAC = 17; + FORMAT_MP4_FLAC = 18; + FORMAT_MP4_Unknown = 19; + FORMAT_MP3_Unknown = 20; +} diff --git a/protocol/proto/media_manifest.proto b/protocol/proto/media_manifest.proto new file mode 100644 index 00000000..6e280259 --- /dev/null +++ b/protocol/proto/media_manifest.proto @@ -0,0 +1,58 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.media_manifest.proto; + +option optimize_for = CODE_SIZE; + +message AudioFile { + enum Format { + OGG_VORBIS_96 = 0; + OGG_VORBIS_160 = 1; + OGG_VORBIS_320 = 2; + MP3_256 = 3; + MP3_320 = 4; + MP3_160 = 5; + MP3_96 = 6; + MP3_160_ENC = 7; + AAC_24 = 8; + AAC_48 = 9; + FLAC_FLAC = 16; + } +} + +message File { + int32 bitrate = 3; + string mime_type = 4; + + oneof file { + ExternalFile external_file = 1; + FileIdFile file_id_file = 2; + } + + message ExternalFile { + string method = 1; + bytes body = 4; + + oneof endpoint { + string url = 2; + string service = 3; + } + } + + message FileIdFile { + string file_id_hex = 1; + AudioFile.Format download_format = 2; + EncryptionType encryption = 3; + } +} + +message Files { + repeated File files = 1; +} + +enum EncryptionType { + NONE = 0; + AES = 1; +} diff --git a/protocol/proto/media_type.proto b/protocol/proto/media_type.proto new file mode 100644 index 00000000..2d8def46 --- /dev/null +++ b/protocol/proto/media_type.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum MediaType { + AUDIO = 0; + VIDEO = 1; +} diff --git a/protocol/proto/media_type_node.proto b/protocol/proto/media_type_node.proto new file mode 100644 index 00000000..0d0a5964 --- /dev/null +++ b/protocol/proto/media_type_node.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message MediaTypeNode { + optional string current_uri = 1; + optional string media_type = 2; + optional string media_manifest_id = 3; +} diff --git a/protocol/proto/members_request.proto b/protocol/proto/members_request.proto new file mode 100644 index 00000000..931f91d3 --- /dev/null +++ b/protocol/proto/members_request.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message OptionalLimit { + uint32 value = 1; +} + +message PlaylistMembersRequest { + string uri = 1; + OptionalLimit limit = 2; +} diff --git a/protocol/proto/members_response.proto b/protocol/proto/members_response.proto new file mode 100644 index 00000000..f341a8d2 --- /dev/null +++ b/protocol/proto/members_response.proto @@ -0,0 +1,35 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "playlist_permission.proto"; +import "playlist_user_state.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message Member { + optional User user = 1; + optional bool is_owner = 2; + optional uint32 num_tracks = 3; + optional uint32 num_episodes = 4; + optional FollowState follow_state = 5; + optional playlist_permission.proto.PermissionLevel permission_level = 6; +} + +message PlaylistMembersResponse { + optional string title = 1; + optional uint32 num_total_members = 2; + optional playlist_permission.proto.Capabilities capabilities = 3; + optional playlist_permission.proto.PermissionLevel base_permission_level = 4; + repeated Member members = 5; +} + +enum FollowState { + NONE = 0; + CAN_BE_FOLLOWED = 1; + CAN_BE_UNFOLLOWED = 2; +} diff --git a/protocol/proto/mergedprofile.proto b/protocol/proto/mergedprofile.proto deleted file mode 100644 index e283e1de..00000000 --- a/protocol/proto/mergedprofile.proto +++ /dev/null @@ -1,10 +0,0 @@ -syntax = "proto2"; - -message MergedProfileRequest { -} - -message MergedProfileReply { - optional string username = 0x1; - optional string artistid = 0x2; -} - diff --git a/protocol/proto/messages/discovery/force_discover.proto b/protocol/proto/messages/discovery/force_discover.proto new file mode 100644 index 00000000..22bcb066 --- /dev/null +++ b/protocol/proto/messages/discovery/force_discover.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connect.esperanto.proto; + +option java_package = "com.spotify.connect.esperanto.proto"; + +message ForceDiscoverRequest { + +} + +message ForceDiscoverResponse { + +} diff --git a/protocol/proto/messages/discovery/start_discovery.proto b/protocol/proto/messages/discovery/start_discovery.proto new file mode 100644 index 00000000..d4af9339 --- /dev/null +++ b/protocol/proto/messages/discovery/start_discovery.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connect.esperanto.proto; + +option java_package = "com.spotify.connect.esperanto.proto"; + +message StartDiscoveryRequest { + +} + +message StartDiscoveryResponse { + +} diff --git a/protocol/proto/metadata.proto b/protocol/proto/metadata.proto index 3812f94e..056dbcfa 100644 --- a/protocol/proto/metadata.proto +++ b/protocol/proto/metadata.proto @@ -1,266 +1,314 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + syntax = "proto2"; -message TopTracks { - optional string country = 0x1; - repeated Track track = 0x2; -} +package spotify.metadata; -message ActivityPeriod { - optional sint32 start_year = 0x1; - optional sint32 end_year = 0x2; - optional sint32 decade = 0x3; -} +option optimize_for = CODE_SIZE; +option java_outer_classname = "Metadata"; +option java_package = "com.spotify.metadata.proto"; message Artist { - optional bytes gid = 0x1; - optional string name = 0x2; - optional sint32 popularity = 0x3; - repeated TopTracks top_track = 0x4; - repeated AlbumGroup album_group = 0x5; - repeated AlbumGroup single_group = 0x6; - repeated AlbumGroup compilation_group = 0x7; - repeated AlbumGroup appears_on_group = 0x8; - repeated string genre = 0x9; - repeated ExternalId external_id = 0xa; - repeated Image portrait = 0xb; - repeated Biography biography = 0xc; - repeated ActivityPeriod activity_period = 0xd; - repeated Restriction restriction = 0xe; - repeated Artist related = 0xf; - optional bool is_portrait_album_cover = 0x10; - optional ImageGroup portrait_group = 0x11; -} - -message AlbumGroup { - repeated Album album = 0x1; -} - -message Date { - optional sint32 year = 0x1; - optional sint32 month = 0x2; - optional sint32 day = 0x3; - optional sint32 hour = 0x4; - optional sint32 minute = 0x5; + optional bytes gid = 1; + optional string name = 2; + optional sint32 popularity = 3; + repeated TopTracks top_track = 4; + repeated AlbumGroup album_group = 5; + repeated AlbumGroup single_group = 6; + repeated AlbumGroup compilation_group = 7; + repeated AlbumGroup appears_on_group = 8; + repeated string genre = 9; + repeated ExternalId external_id = 10; + repeated Image portrait = 11; + repeated Biography biography = 12; + repeated ActivityPeriod activity_period = 13; + repeated Restriction restriction = 14; + repeated Artist related = 15; + optional bool is_portrait_album_cover = 16; + optional ImageGroup portrait_group = 17; + repeated SalePeriod sale_period = 18; + repeated Availability availability = 20; } message Album { - optional bytes gid = 0x1; - optional string name = 0x2; - repeated Artist artist = 0x3; - optional Type typ = 0x4; + optional bytes gid = 1; + optional string name = 2; + repeated Artist artist = 3; + + optional Type type = 4; enum Type { - ALBUM = 0x1; - SINGLE = 0x2; - COMPILATION = 0x3; - EP = 0x4; + ALBUM = 1; + SINGLE = 2; + COMPILATION = 3; + EP = 4; + AUDIOBOOK = 5; + PODCAST = 6; } - optional string label = 0x5; - optional Date date = 0x6; - optional sint32 popularity = 0x7; - repeated string genre = 0x8; - repeated Image cover = 0x9; - repeated ExternalId external_id = 0xa; - repeated Disc disc = 0xb; - repeated string review = 0xc; - repeated Copyright copyright = 0xd; - repeated Restriction restriction = 0xe; - repeated Album related = 0xf; - repeated SalePeriod sale_period = 0x10; - optional ImageGroup cover_group = 0x11; + + optional string label = 5; + optional Date date = 6; + optional sint32 popularity = 7; + repeated string genre = 8; + repeated Image cover = 9; + repeated ExternalId external_id = 10; + repeated Disc disc = 11; + repeated string review = 12; + repeated Copyright copyright = 13; + repeated Restriction restriction = 14; + repeated Album related = 15; + repeated SalePeriod sale_period = 16; + optional ImageGroup cover_group = 17; + optional string original_title = 18; + optional string version_title = 19; + optional string type_str = 20; + repeated Availability availability = 23; } message Track { - optional bytes gid = 0x1; - optional string name = 0x2; - optional Album album = 0x3; - repeated Artist artist = 0x4; - optional sint32 number = 0x5; - optional sint32 disc_number = 0x6; - optional sint32 duration = 0x7; - optional sint32 popularity = 0x8; - optional bool explicit = 0x9; - repeated ExternalId external_id = 0xa; - repeated Restriction restriction = 0xb; - repeated AudioFile file = 0xc; - repeated Track alternative = 0xd; - repeated SalePeriod sale_period = 0xe; - repeated AudioFile preview = 0xf; + optional bytes gid = 1; + optional string name = 2; + optional Album album = 3; + repeated Artist artist = 4; + optional sint32 number = 5; + optional sint32 disc_number = 6; + optional sint32 duration = 7; + optional sint32 popularity = 8; + optional bool explicit = 9; + repeated ExternalId external_id = 10; + repeated Restriction restriction = 11; + repeated AudioFile file = 12; + repeated Track alternative = 13; + repeated SalePeriod sale_period = 14; + repeated AudioFile preview = 15; + repeated string tags = 16; + optional int64 earliest_live_timestamp = 17; + optional bool has_lyrics = 18; + repeated Availability availability = 19; + optional Licensor licensor = 21; + repeated string language_of_performance = 22; + repeated ContentRating content_rating = 25; + optional string original_title = 27; + optional string version_title = 28; + repeated ArtistWithRole artist_with_role = 32; +} + +message ArtistWithRole { + optional bytes artist_gid = 1; + optional string artist_name = 2; + + optional ArtistRole role = 3; + enum ArtistRole { + ARTIST_ROLE_UNKNOWN = 0; + ARTIST_ROLE_MAIN_ARTIST = 1; + ARTIST_ROLE_FEATURED_ARTIST = 2; + ARTIST_ROLE_REMIXER = 3; + ARTIST_ROLE_ACTOR = 4; + ARTIST_ROLE_COMPOSER = 5; + ARTIST_ROLE_CONDUCTOR = 6; + ARTIST_ROLE_ORCHESTRA = 7; + } +} + +message Show { + optional bytes gid = 1; + optional string name = 2; + optional string description = 64; + optional sint32 deprecated_popularity = 65; + optional string publisher = 66; + optional string language = 67; + optional bool explicit = 68; + optional ImageGroup cover_image = 69; + repeated Episode episode = 70; + repeated Copyright copyright = 71; + repeated Restriction restriction = 72; + repeated string keyword = 73; + + optional MediaType media_type = 74; + enum MediaType { + MIXED = 0; + AUDIO = 1; + VIDEO = 2; + } + + optional ConsumptionOrder consumption_order = 75; + enum ConsumptionOrder { + SEQUENTIAL = 1; + EPISODIC = 2; + RECENT = 3; + } + + repeated Availability availability = 78; + optional string trailer_uri = 83; + optional bool music_and_talk = 85; + optional bool is_audiobook = 89; +} + +message Episode { + optional bytes gid = 1; + optional string name = 2; + optional sint32 duration = 7; + repeated AudioFile audio = 12; + optional string description = 64; + optional sint32 number = 65; + optional Date publish_time = 66; + optional sint32 deprecated_popularity = 67; + optional ImageGroup cover_image = 68; + optional string language = 69; + optional bool explicit = 70; + optional Show show = 71; + repeated VideoFile video = 72; + repeated VideoFile video_preview = 73; + repeated AudioFile audio_preview = 74; + repeated Restriction restriction = 75; + optional ImageGroup freeze_frame = 76; + repeated string keyword = 77; + optional bool allow_background_playback = 81; + repeated Availability availability = 82; + optional string external_url = 83; + + optional EpisodeType type = 87; + enum EpisodeType { + FULL = 0; + TRAILER = 1; + BONUS = 2; + } + + optional bool music_and_talk = 91; + repeated ContentRating content_rating = 95; + optional bool is_audiobook_chapter = 96; +} + +message Licensor { + optional bytes uuid = 1; +} + +message TopTracks { + optional string country = 1; + repeated Track track = 2; +} + +message ActivityPeriod { + optional sint32 start_year = 1; + optional sint32 end_year = 2; + optional sint32 decade = 3; +} + +message AlbumGroup { + repeated Album album = 1; +} + +message Date { + optional sint32 year = 1; + optional sint32 month = 2; + optional sint32 day = 3; + optional sint32 hour = 4; + optional sint32 minute = 5; } message Image { - optional bytes file_id = 0x1; - optional Size size = 0x2; + optional bytes file_id = 1; + + optional Size size = 2; enum Size { - DEFAULT = 0x0; - SMALL = 0x1; - LARGE = 0x2; - XLARGE = 0x3; + DEFAULT = 0; + SMALL = 1; + LARGE = 2; + XLARGE = 3; } - optional sint32 width = 0x3; - optional sint32 height = 0x4; + + optional sint32 width = 3; + optional sint32 height = 4; } message ImageGroup { - repeated Image image = 0x1; + repeated Image image = 1; } message Biography { - optional string text = 0x1; - repeated Image portrait = 0x2; - repeated ImageGroup portrait_group = 0x3; + optional string text = 1; + repeated Image portrait = 2; + repeated ImageGroup portrait_group = 3; } message Disc { - optional sint32 number = 0x1; - optional string name = 0x2; - repeated Track track = 0x3; + optional sint32 number = 1; + optional string name = 2; + repeated Track track = 3; } message Copyright { - optional Type typ = 0x1; + optional Type type = 1; enum Type { - P = 0x0; - C = 0x1; + P = 0; + C = 1; } - optional string text = 0x2; + + optional string text = 2; } message Restriction { + repeated Catalogue catalogue = 1; enum Catalogue { - AD = 0; - SUBSCRIPTION = 1; - CATALOGUE_ALL = 2; - SHUFFLE = 3; - COMMERCIAL = 4; + AD = 0; + SUBSCRIPTION = 1; + CATALOGUE_ALL = 2; + SHUFFLE = 3; + COMMERCIAL = 4; } + + optional Type type = 4; enum Type { - STREAMING = 0x0; + STREAMING = 0; + } + + repeated string catalogue_str = 5; + + oneof country_restriction { + string countries_allowed = 2; + string countries_forbidden = 3; } - repeated Catalogue catalogue = 0x1; - optional string countries_allowed = 0x2; - optional string countries_forbidden = 0x3; - optional Type typ = 0x4; - - repeated string catalogue_str = 0x5; } message Availability { - repeated string catalogue_str = 0x1; - optional Date start = 0x2; + repeated string catalogue_str = 1; + optional Date start = 2; } message SalePeriod { - repeated Restriction restriction = 0x1; - optional Date start = 0x2; - optional Date end = 0x3; + repeated Restriction restriction = 1; + optional Date start = 2; + optional Date end = 3; } message ExternalId { - optional string typ = 0x1; - optional string id = 0x2; + optional string type = 1; + optional string id = 2; } message AudioFile { - optional bytes file_id = 0x1; - optional Format format = 0x2; + optional bytes file_id = 1; + + optional Format format = 2; enum Format { - OGG_VORBIS_96 = 0x0; - OGG_VORBIS_160 = 0x1; - OGG_VORBIS_320 = 0x2; - MP3_256 = 0x3; - MP3_320 = 0x4; - MP3_160 = 0x5; - MP3_96 = 0x6; - MP3_160_ENC = 0x7; - // v4 - // AAC_24 = 0x8; - // AAC_48 = 0x9; - MP4_128_DUAL = 0x8; - OTHER3 = 0x9; - AAC_160 = 0xa; - AAC_320 = 0xb; - MP4_128 = 0xc; - OTHER5 = 0xd; + OGG_VORBIS_96 = 0; + OGG_VORBIS_160 = 1; + OGG_VORBIS_320 = 2; + MP3_256 = 3; + MP3_320 = 4; + MP3_160 = 5; + MP3_96 = 6; + MP3_160_ENC = 7; + AAC_24 = 8; + AAC_48 = 9; + FLAC_FLAC = 16; } } message VideoFile { - optional bytes file_id = 1; + optional bytes file_id = 1; } -// Podcast Protos -message Show { - enum MediaType { - MIXED = 0; - AUDIO = 1; - VIDEO = 2; - } - enum ConsumptionOrder { - SEQUENTIAL = 1; - EPISODIC = 2; - RECENT = 3; - } - enum PassthroughEnum { - UNKNOWN = 0; - NONE = 1; - ALLOWED = 2; - } - optional bytes gid = 0x1; - optional string name = 0x2; - optional string description = 0x40; - optional sint32 deprecated_popularity = 0x41; - optional string publisher = 0x42; - optional string language = 0x43; - optional bool explicit = 0x44; - optional ImageGroup covers = 0x45; - repeated Episode episode = 0x46; - repeated Copyright copyright = 0x47; - repeated Restriction restriction = 0x48; - repeated string keyword = 0x49; - optional MediaType media_type = 0x4A; - optional ConsumptionOrder consumption_order = 0x4B; - optional bool interpret_restriction_using_geoip = 0x4C; - repeated Availability availability = 0x4E; - optional string country_of_origin = 0x4F; - repeated Category categories = 0x50; - optional PassthroughEnum passthrough = 0x51; -} - -message Episode { - optional bytes gid = 0x1; - optional string name = 0x2; - optional sint32 duration = 0x7; - optional sint32 popularity = 0x8; - repeated AudioFile file = 0xc; - optional string description = 0x40; - optional sint32 number = 0x41; - optional Date publish_time = 0x42; - optional sint32 deprecated_popularity = 0x43; - optional ImageGroup covers = 0x44; - optional string language = 0x45; - optional bool explicit = 0x46; - optional Show show = 0x47; - repeated VideoFile video = 0x48; - repeated VideoFile video_preview = 0x49; - repeated AudioFile audio_preview = 0x4A; - repeated Restriction restriction = 0x4B; - optional ImageGroup freeze_frame = 0x4C; - repeated string keyword = 0x4D; - // Order of these two flags might be wrong! - optional bool suppress_monetization = 0x4E; - optional bool interpret_restriction_using_geoip = 0x4F; - - optional bool allow_background_playback = 0x51; - repeated Availability availability = 0x52; - optional string external_url = 0x53; - optional OriginalAudio original_audio = 0x54; -} - -message Category { - optional string name = 0x1; - repeated Category subcategories = 0x2; -} - -message OriginalAudio { - optional bytes uuid = 0x1; +message ContentRating { + optional string country = 1; + repeated string tag = 2; } diff --git a/protocol/proto/metadata/album_metadata.proto b/protocol/proto/metadata/album_metadata.proto new file mode 100644 index 00000000..5a7de5f9 --- /dev/null +++ b/protocol/proto/metadata/album_metadata.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message AlbumArtistMetadata { + optional string link = 1; + optional string name = 2; +} + +message AlbumMetadata { + repeated AlbumArtistMetadata artists = 1; + optional string link = 2; + optional string name = 3; + repeated string copyright = 4; + optional ImageGroup covers = 5; + optional uint32 year = 6; + optional uint32 num_discs = 7; + optional uint32 num_tracks = 8; + optional bool playability = 9; + optional bool is_premium_only = 10; +} diff --git a/protocol/proto/metadata/artist_metadata.proto b/protocol/proto/metadata/artist_metadata.proto new file mode 100644 index 00000000..4e5e9bfe --- /dev/null +++ b/protocol/proto/metadata/artist_metadata.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ArtistMetadata { + optional string link = 1; + optional string name = 2; + optional bool is_various_artists = 3; + optional ImageGroup portraits = 4; +} diff --git a/protocol/proto/metadata/episode_metadata.proto b/protocol/proto/metadata/episode_metadata.proto new file mode 100644 index 00000000..5d4a0b25 --- /dev/null +++ b/protocol/proto/metadata/episode_metadata.proto @@ -0,0 +1,63 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/extension.proto"; +import "metadata/image_group.proto"; +import "podcast_segments.proto"; +import "podcast_subscription.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message EpisodeShowMetadata { + optional string link = 1; + optional string name = 2; + optional string publisher = 3; + optional ImageGroup covers = 4; +} + +message EpisodeMetadata { + optional EpisodeShowMetadata show = 1; + optional string link = 2; + optional string name = 3; + optional uint32 length = 4; + optional ImageGroup covers = 5; + optional string manifest_id = 6; + optional string description = 7; + optional int64 publish_date = 8; + optional ImageGroup freeze_frames = 9; + optional string language = 10; + optional bool available = 11; + + optional MediaType media_type_enum = 12; + enum MediaType { + VODCAST = 0; + AUDIO = 1; + VIDEO = 2; + } + + optional int32 number = 13; + optional bool backgroundable = 14; + optional string preview_manifest_id = 15; + optional bool is_explicit = 16; + optional string preview_id = 17; + + optional EpisodeType episode_type = 18; + enum EpisodeType { + UNKNOWN = 0; + FULL = 1; + TRAILER = 2; + BONUS = 3; + } + + optional bool is_music_and_talk = 19; + optional podcast_segments.PodcastSegments podcast_segments = 20; + optional podcast_paywalls.PodcastSubscription podcast_subscription = 21; + repeated Extension extension = 22; + optional bool is_19_plus_only = 23; + optional bool is_book_chapter = 24; +} diff --git a/protocol/proto/metadata/extension.proto b/protocol/proto/metadata/extension.proto new file mode 100644 index 00000000..b10a0f08 --- /dev/null +++ b/protocol/proto/metadata/extension.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "extension_kind.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message Extension { + optional extendedmetadata.ExtensionKind extension_kind = 1; + optional bytes data = 2; +} diff --git a/protocol/proto/metadata/image_group.proto b/protocol/proto/metadata/image_group.proto new file mode 100644 index 00000000..310a408b --- /dev/null +++ b/protocol/proto/metadata/image_group.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ImageGroup { + optional string standard_link = 1; + optional string small_link = 2; + optional string large_link = 3; + optional string xlarge_link = 4; +} diff --git a/protocol/proto/metadata/show_metadata.proto b/protocol/proto/metadata/show_metadata.proto new file mode 100644 index 00000000..9b9891d3 --- /dev/null +++ b/protocol/proto/metadata/show_metadata.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/extension.proto"; +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ShowMetadata { + optional string link = 1; + optional string name = 2; + optional string description = 3; + optional uint32 popularity = 4; + optional string publisher = 5; + optional string language = 6; + optional bool is_explicit = 7; + optional ImageGroup covers = 8; + optional uint32 num_episodes = 9; + optional string consumption_order = 10; + optional int32 media_type_enum = 11; + repeated string copyright = 12; + optional string trailer_uri = 13; + optional bool is_music_and_talk = 14; + repeated Extension extension = 15; + optional bool is_book = 16; +} diff --git a/protocol/proto/metadata/track_metadata.proto b/protocol/proto/metadata/track_metadata.proto new file mode 100644 index 00000000..08bff401 --- /dev/null +++ b/protocol/proto/metadata/track_metadata.proto @@ -0,0 +1,55 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message TrackAlbumArtistMetadata { + optional string link = 1; + optional string name = 2; +} + +message TrackAlbumMetadata { + optional TrackAlbumArtistMetadata artist = 1; + optional string link = 2; + optional string name = 3; + optional ImageGroup covers = 4; +} + +message TrackArtistMetadata { + optional string link = 1; + optional string name = 2; + optional ImageGroup portraits = 3; +} + +message TrackDescriptor { + optional string name = 1; +} + +message TrackMetadata { + optional TrackAlbumMetadata album = 1; + repeated TrackArtistMetadata artist = 2; + optional string link = 3; + optional string name = 4; + optional uint32 length = 5; + optional bool playable = 6; + optional uint32 disc_number = 7; + optional uint32 track_number = 8; + optional bool is_explicit = 9; + optional string preview_id = 10; + optional bool is_local = 11; + optional bool playable_local_track = 12; + optional bool has_lyrics = 13; + optional bool is_premium_only = 14; + optional bool locally_playable = 15; + optional string playable_track_link = 16; + optional uint32 popularity = 17; + optional bool is_19_plus_only = 18; + repeated TrackDescriptor track_descriptors = 19; +} diff --git a/protocol/proto/metadata_cosmos.proto b/protocol/proto/metadata_cosmos.proto new file mode 100644 index 00000000..f04e5957 --- /dev/null +++ b/protocol/proto/metadata_cosmos.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.metadata_cosmos.proto; + +import "metadata.proto"; + +option optimize_for = CODE_SIZE; +option java_outer_classname = "MetadataCosmos"; +option java_package = "com.spotify.metadata.cosmos.proto"; + +message MetadataItem { + oneof item { + sint32 error = 1; + metadata.Artist artist = 2; + metadata.Album album = 3; + metadata.Track track = 4; + metadata.Show show = 5; + metadata.Episode episode = 6; + } +} + +message MultiResponse { + repeated MetadataItem items = 1; +} + +message MultiRequest { + repeated string uris = 1; +} diff --git a/protocol/proto/metadata_esperanto.proto b/protocol/proto/metadata_esperanto.proto new file mode 100644 index 00000000..601290a1 --- /dev/null +++ b/protocol/proto/metadata_esperanto.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.metadata_esperanto.proto; + +import "metadata_cosmos.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.metadata.esperanto.proto"; + +service ClassicMetadataService { + rpc GetEntity(GetEntityRequest) returns (GetEntityResponse); + rpc MultigetEntity(metadata_cosmos.proto.MultiRequest) returns (metadata_cosmos.proto.MultiResponse); +} + +message GetEntityRequest { + string uri = 1; +} + +message GetEntityResponse { + metadata_cosmos.proto.MetadataItem item = 1; +} diff --git a/protocol/proto/mod.rs b/protocol/proto/mod.rs index 9dfc8c92..24cf4052 100644 --- a/protocol/proto/mod.rs +++ b/protocol/proto/mod.rs @@ -1,4 +1,2 @@ // generated protobuf files will be included here. See build.rs for details -#![allow(renamed_and_removed_lints)] - include!(env!("PROTO_MOD_RS")); diff --git a/protocol/proto/modification_request.proto b/protocol/proto/modification_request.proto new file mode 100644 index 00000000..d35b613c --- /dev/null +++ b/protocol/proto/modification_request.proto @@ -0,0 +1,37 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message ModificationRequest { + optional string operation = 1; + optional string before = 2; + optional string after = 3; + optional string name = 4; + optional bool playlist = 5; + + optional Attributes attributes = 6; + message Attributes { + optional bool published = 1; + optional bool collaborative = 2; + optional string name = 3; + optional string description = 4; + optional string imageUri = 5; + optional string picture = 6; + } + + repeated string uris = 7; + repeated string rows = 8; + optional bool contents = 9; + optional string item_id = 10; +} + +message ModificationResponse { + optional bool success = 1; + optional string uri = 2; +} diff --git a/protocol/proto/net-fortune.proto b/protocol/proto/net-fortune.proto new file mode 100644 index 00000000..dbf476b2 --- /dev/null +++ b/protocol/proto/net-fortune.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.netfortune.proto; + +option optimize_for = CODE_SIZE; + +message NetFortuneResponse { + int32 advised_audio_bitrate = 1; +} + +message NetFortuneV2Response { + string predict_id = 1; + int32 estimated_max_bitrate = 2; +} diff --git a/protocol/proto/offline.proto b/protocol/proto/offline.proto new file mode 100644 index 00000000..b3d12491 --- /dev/null +++ b/protocol/proto/offline.proto @@ -0,0 +1,83 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.offline.proto; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + +option optimize_for = CODE_SIZE; + +message Capacity { + double total_space = 1; + double free_space = 2; + double offline_space = 3; + uint64 track_count = 4; + uint64 episode_count = 5; +} + +message Device { + string device_id = 1; + string cache_id = 2; + string name = 3; + int32 type = 4; + int32 platform = 5; + bool offline_enabled = 6; + Capacity capacity = 7; + google.protobuf.Timestamp updated_at = 9; + google.protobuf.Timestamp last_seen_at = 10; + bool removal_pending = 11; +} + +message Restrictions { + google.protobuf.Duration allowed_duration_tracks = 1; + uint64 max_tracks = 2; + google.protobuf.Duration allowed_duration_episodes = 3; + uint64 max_episodes = 4; +} + +message Resource { + string uri = 1; + ResourceState state = 2; + int32 progress = 3; + google.protobuf.Timestamp updated_at = 4; + string failure_message = 5; +} + +message DeviceKey { + string user_id = 1; + string device_id = 2; + string cache_id = 3; +} + +message ResourceForDevice { + string device_id = 1; + string cache_id = 2; + Resource resource = 3; +} + +message ResourceOperation { + Operation operation = 2; + enum Operation { + INVALID = 0; + ADD = 1; + REMOVE = 2; + } + + string uri = 3; +} + +message ResourceHistoryItem { + repeated ResourceOperation operations = 1; + google.protobuf.Timestamp server_time = 2; +} + +enum ResourceState { + UNSPECIFIED = 0; + REQUESTED = 1; + PENDING = 2; + DOWNLOADING = 3; + DOWNLOADED = 4; + FAILURE = 5; +} diff --git a/protocol/proto/offline_playlists_containing.proto b/protocol/proto/offline_playlists_containing.proto new file mode 100644 index 00000000..3d75865f --- /dev/null +++ b/protocol/proto/offline_playlists_containing.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option objc_class_prefix = "SPTPlaylist"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message OfflinePlaylistContainingItem { + required string playlist_link = 1; + optional string playlist_name = 2; +} + +message OfflinePlaylistsContainingItemResponse { + repeated OfflinePlaylistContainingItem playlists = 1; +} diff --git a/protocol/proto/on_demand_in_free_reason.proto b/protocol/proto/on_demand_in_free_reason.proto new file mode 100644 index 00000000..bf3a820e --- /dev/null +++ b/protocol/proto/on_demand_in_free_reason.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.on_demand_set.proto; + +option optimize_for = CODE_SIZE; + +enum OnDemandInFreeReason { + UNKNOWN = 0; + NOT_ON_DEMAND = 1; + ON_DEMAND = 2; + ON_DEMAND_EPISODES_ONLY = 3; +} diff --git a/protocol/proto/on_demand_set_cosmos_request.proto b/protocol/proto/on_demand_set_cosmos_request.proto new file mode 100644 index 00000000..72d4d3d9 --- /dev/null +++ b/protocol/proto/on_demand_set_cosmos_request.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.on_demand_set_cosmos.proto; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.on_demand_set.proto"; + +message Set { + repeated string uris = 1; +} + +message Temporary { + optional string uri = 1; + optional int64 valid_for_in_seconds = 2; +} diff --git a/protocol/proto/on_demand_set_cosmos_response.proto b/protocol/proto/on_demand_set_cosmos_response.proto new file mode 100644 index 00000000..8ca68cbe --- /dev/null +++ b/protocol/proto/on_demand_set_cosmos_response.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.on_demand_set_cosmos.proto; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.on_demand_set.proto"; + +message Response { + optional bool success = 1; +} diff --git a/protocol/proto/on_demand_set_response.proto b/protocol/proto/on_demand_set_response.proto new file mode 100644 index 00000000..9d914dd7 --- /dev/null +++ b/protocol/proto/on_demand_set_response.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.on_demand_set_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.on_demand_set.proto"; + +message ResponseStatus { + int32 status_code = 1; + string reason = 2; +} diff --git a/protocol/proto/pending_event_entity.proto b/protocol/proto/pending_event_entity.proto new file mode 100644 index 00000000..0dd5c099 --- /dev/null +++ b/protocol/proto/pending_event_entity.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.pending_events.proto; + +option optimize_for = CODE_SIZE; + +message PendingEventEntity { + string event_name = 1; + bytes payload = 2; + string username = 3; +} diff --git a/protocol/proto/perf_metrics_service.proto b/protocol/proto/perf_metrics_service.proto new file mode 100644 index 00000000..484bd321 --- /dev/null +++ b/protocol/proto/perf_metrics_service.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.perf_metrics.esperanto.proto; + +option java_package = "com.spotify.perf_metrics.esperanto.proto"; + +service PerfMetricsService { + rpc TerminateState(PerfMetricsRequest) returns (PerfMetricsResponse); +} + +message PerfMetricsRequest { + string terminal_state = 1; + bool foreground_startup = 2; +} + +message PerfMetricsResponse { + bool success = 1; +} diff --git a/protocol/proto/pin_request.proto b/protocol/proto/pin_request.proto new file mode 100644 index 00000000..a5337320 --- /dev/null +++ b/protocol/proto/pin_request.proto @@ -0,0 +1,34 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option optimize_for = CODE_SIZE; + +message PinRequest { + string uri = 1; +} + +message PinResponse { + PinStatus status = 1; + enum PinStatus { + UNKNOWN = 0; + PINNED = 1; + NOT_PINNED = 2; + } + + bool has_maximum_pinned_items = 2; + int32 maximum_pinned_items = 3; + string error = 99; +} + +message PinItem { + string uri = 1; + bool in_library = 2; +} + +message PinList { + repeated PinItem item = 1; + string error = 99; +} diff --git a/protocol/proto/play_origin.proto b/protocol/proto/play_origin.proto new file mode 100644 index 00000000..53bb0706 --- /dev/null +++ b/protocol/proto/play_origin.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message PlayOrigin { + optional string feature_identifier = 1; + optional string feature_version = 2; + optional string view_uri = 3; + optional string external_referrer = 4; + optional string referrer_identifier = 5; + optional string device_identifier = 6; + repeated string feature_classes = 7; +} diff --git a/protocol/proto/play_queue_node.proto b/protocol/proto/play_queue_node.proto new file mode 100644 index 00000000..d79a9825 --- /dev/null +++ b/protocol/proto/play_queue_node.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_track.proto"; +import "track_instance.proto"; +import "track_instantiator.proto"; + +option optimize_for = CODE_SIZE; + +message PlayQueueNode { + repeated ContextTrack queue = 1; + optional TrackInstance instance = 2; + optional TrackInstantiator instantiator = 3; + optional uint32 next_uid = 4; + optional sint32 iteration = 5; +} diff --git a/protocol/proto/play_reason.proto b/protocol/proto/play_reason.proto new file mode 100644 index 00000000..04bba83f --- /dev/null +++ b/protocol/proto/play_reason.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum PlayReason { + PLAY_REASON_UNKNOWN = 0; + PLAY_REASON_APP_LOAD = 1; + PLAY_REASON_BACK_BTN = 2; + PLAY_REASON_CLICK_ROW = 3; + PLAY_REASON_CLICK_SIDE = 4; + PLAY_REASON_END_PLAY = 5; + PLAY_REASON_FWD_BTN = 6; + PLAY_REASON_INTERRUPTED = 7; + PLAY_REASON_LOGOUT = 8; + PLAY_REASON_PLAY_BTN = 9; + PLAY_REASON_POPUP = 10; + PLAY_REASON_REMOTE = 11; + PLAY_REASON_SONG_DONE = 12; + PLAY_REASON_TRACK_DONE = 13; + PLAY_REASON_TRACK_ERROR = 14; + PLAY_REASON_PREVIEW = 15; + PLAY_REASON_URI_OPEN = 16; + PLAY_REASON_BACKGROUNDED = 17; + PLAY_REASON_OFFLINE = 18; + PLAY_REASON_UNEXPECTED_EXIT = 19; + PLAY_REASON_UNEXPECTED_EXIT_WHILE_PAUSED = 20; +} diff --git a/protocol/proto/playback.proto b/protocol/proto/playback.proto new file mode 100644 index 00000000..94d8ae7e --- /dev/null +++ b/protocol/proto/playback.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message Playback { + optional int64 timestamp = 1; + optional int32 position_as_of_timestamp = 2; + optional double playback_speed = 3; + optional bool is_paused = 4; + optional ContextTrack current_track = 5; +} diff --git a/protocol/proto/playback_cosmos.proto b/protocol/proto/playback_cosmos.proto new file mode 100644 index 00000000..b2ae4f96 --- /dev/null +++ b/protocol/proto/playback_cosmos.proto @@ -0,0 +1,106 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playback_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message VolumeRequest { + oneof source_or_system { + VolumeChangeSource source = 1; + bool system_initiated = 4; + } + + oneof action { + double volume = 2; + Step step = 3; + } + + enum Step { + option allow_alias = true; + up = 0; + UP = 0; + down = 1; + DOWN = 1; + } +} + +message VolumeResponse { + double volume = 1; +} + +message VolumeSubResponse { + double volume = 1; + VolumeChangeSource source = 2; + bool system_initiated = 3; +} + +message PositionResponseV1 { + int32 position = 1; +} + +message PositionResponseV2 { + int64 position = 1; +} + +message InfoResponse { + bool has_info = 1; + uint64 length_ms = 2; + uint64 position_ms = 3; + bool playing = 4; + bool buffering = 5; + int32 error = 6; + string file_id = 7; + string file_type = 8; + string resolved_content_url = 9; + int32 file_bitrate = 10; + string codec_name = 11; + double playback_speed = 12; + float gain_adjustment = 13; + bool has_loudness = 14; + float loudness = 15; + string strategy = 17; + int32 target_bitrate = 18; + int32 advised_bitrate = 19; + bool target_file_available = 20; + + reserved 16; +} + +message FormatsResponse { + repeated Format formats = 1; + message Format { + string enum_key = 1; + uint32 enum_value = 2; + bool supported = 3; + uint32 bitrate = 4; + string mime_type = 5; + } +} + +message GetFilesResponse { + repeated File files = 1; + message File { + string file_id = 1; + string format = 2; + uint32 bitrate = 3; + uint32 format_enum = 4; + } +} + +message DuckRequest { + Action action = 2; + enum Action { + START = 0; + STOP = 1; + } + + double volume = 3; + uint32 fade_duration_ms = 4; +} + +enum VolumeChangeSource { + USER = 0; + SYSTEM = 1; +} diff --git a/protocol/proto/playback_esperanto.proto b/protocol/proto/playback_esperanto.proto new file mode 100644 index 00000000..3c57325a --- /dev/null +++ b/protocol/proto/playback_esperanto.proto @@ -0,0 +1,122 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playback_esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playback_esperanto.proto"; + +message GetVolumeResponse { + Status status = 1; + double volume = 2; +} + +message SubVolumeResponse { + Status status = 1; + double volume = 2; + VolumeChangeSource source = 3; +} + +message SetVolumeRequest { + VolumeChangeSource source = 1; + double volume = 2; +} + +message NudgeVolumeRequest { + VolumeChangeSource source = 1; +} + +message PlaybackInfoResponse { + Status status = 1; + uint64 length_ms = 2; + uint64 position_ms = 3; + bool playing = 4; + bool buffering = 5; + int32 error = 6; + string file_id = 7; + string file_type = 8; + string resolved_content_url = 9; + int32 file_bitrate = 10; + string codec_name = 11; + double playback_speed = 12; + float gain_adjustment = 13; + bool has_loudness = 14; + float loudness = 15; + string strategy = 17; + int32 target_bitrate = 18; + int32 advised_bitrate = 19; + bool target_file_available = 20; + + reserved 16; +} + +message GetFormatsResponse { + repeated Format formats = 1; + message Format { + string enum_key = 1; + uint32 enum_value = 2; + bool supported = 3; + uint32 bitrate = 4; + string mime_type = 5; + } +} + +message SubPositionRequest { + uint64 position = 1; +} + +message SubPositionResponse { + Status status = 1; + uint64 position = 2; +} + +message GetFilesRequest { + string uri = 1; +} + +message GetFilesResponse { + GetFilesStatus status = 1; + + repeated File files = 2; + message File { + string file_id = 1; + string format = 2; + uint32 bitrate = 3; + uint32 format_enum = 4; + } +} + +message DuckRequest { + Action action = 2; + enum Action { + START = 0; + STOP = 1; + } + + double volume = 3; + uint32 fade_duration_ms = 4; +} + +message DuckResponse { + Status status = 1; +} + +enum Status { + OK = 0; + NOT_AVAILABLE = 1; +} + +enum GetFilesStatus { + GETFILES_OK = 0; + METADATA_CLIENT_NOT_AVAILABLE = 1; + FILES_NOT_FOUND = 2; + TRACK_NOT_AVAILABLE = 3; + EXTENDED_METADATA_ERROR = 4; +} + +enum VolumeChangeSource { + USER = 0; + SYSTEM = 1; +} diff --git a/protocol/proto/playback_platform.proto b/protocol/proto/playback_platform.proto new file mode 100644 index 00000000..5f50bd95 --- /dev/null +++ b/protocol/proto/playback_platform.proto @@ -0,0 +1,90 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playback_platform.proto; + +import "media_manifest.proto"; + +option optimize_for = CODE_SIZE; + +message Media { + string id = 1; + int32 start_position = 6; + int32 stop_position = 7; + + oneof source { + string audio_id = 2; + string episode_id = 3; + string track_id = 4; + media_manifest.proto.Files files = 5; + } +} + +message Annotation { + map metadata = 2; +} + +message PlaybackControl { + +} + +message Context { + string id = 2; + string type = 3; + + reserved 1; +} + +message Timeline { + repeated MediaTrack media_tracks = 1; + message MediaTrack { + repeated Item items = 1; + message Item { + repeated Annotation annotations = 3; + repeated PlaybackControl controls = 4; + + oneof content { + Context context = 1; + Media media = 2; + } + } + } +} + +message PageId { + Context context = 1; + int32 index = 2; +} + +message PagePath { + repeated PageId segments = 1; +} + +message Page { + Header header = 1; + message Header { + int32 status_code = 1; + int32 num_pages = 2; + } + + PageId page_id = 2; + Timeline timeline = 3; +} + +message PageList { + repeated Page pages = 1; +} + +message PageMultiGetRequest { + repeated PageId page_ids = 1; +} + +message PageMultiGetResponse { + repeated Page pages = 1; +} + +message ContextPagePathState { + PagePath path = 1; + repeated int32 media_track_item_index = 3; +} diff --git a/protocol/proto/playback_segments.proto b/protocol/proto/playback_segments.proto new file mode 100644 index 00000000..1f6f6ea8 --- /dev/null +++ b/protocol/proto/playback_segments.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments.playback; + +import "podcast_segments.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PlaybackSegmentsProto"; +option java_package = "com.spotify.podcastsegments.playback.proto"; + +message PlaybackSegments { + repeated PlaybackSegment playback_segments = 1; +} diff --git a/protocol/proto/played_state.proto b/protocol/proto/played_state.proto new file mode 100644 index 00000000..58990254 --- /dev/null +++ b/protocol/proto/played_state.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.played_state.proto; + +option optimize_for = CODE_SIZE; + +message PlayedStateItem { + optional string show_uri = 1; + optional string episode_uri = 2; + optional int32 resume_point = 3; + optional int32 last_played_at = 4; + optional bool is_latest = 5; + optional bool has_been_fully_played = 6; + optional bool has_been_synced = 7; + optional int32 episode_length = 8; +} + +message PlayedStateItems { + repeated PlayedStateItem item = 1; + optional uint64 last_server_sync_timestamp = 2; +} diff --git a/protocol/proto/played_state/episode_played_state.proto b/protocol/proto/played_state/episode_played_state.proto new file mode 100644 index 00000000..696b2e7a --- /dev/null +++ b/protocol/proto/played_state/episode_played_state.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "played_state/playability_restriction.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message EpisodePlayState { + optional uint32 time_left = 1; + optional bool is_playable = 2; + optional bool is_played = 3; + optional uint32 last_played_at = 4; + optional PlayabilityRestriction playability_restriction = 5 [default = UNKNOWN]; +} diff --git a/protocol/proto/played_state/playability_restriction.proto b/protocol/proto/played_state/playability_restriction.proto new file mode 100644 index 00000000..d6de6f4e --- /dev/null +++ b/protocol/proto/played_state/playability_restriction.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +enum PlayabilityRestriction { + UNKNOWN = 0; + NO_RESTRICTION = 1; + EXPLICIT_CONTENT = 2; + AGE_RESTRICTED = 3; + NOT_IN_CATALOGUE = 4; + NOT_AVAILABLE_OFFLINE = 5; +} diff --git a/protocol/proto/played_state/show_played_state.proto b/protocol/proto/played_state/show_played_state.proto new file mode 100644 index 00000000..47f13ec7 --- /dev/null +++ b/protocol/proto/played_state/show_played_state.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option objc_class_prefix = "SPTCosmosUtil"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ShowPlayState { + optional string latest_played_episode_link = 1; +} diff --git a/protocol/proto/played_state/track_played_state.proto b/protocol/proto/played_state/track_played_state.proto new file mode 100644 index 00000000..3a8e4c86 --- /dev/null +++ b/protocol/proto/played_state/track_played_state.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "played_state/playability_restriction.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message TrackPlayState { + optional bool is_playable = 1; + optional PlayabilityRestriction playability_restriction = 2 [default = UNKNOWN]; +} diff --git a/protocol/proto/playedstate.proto b/protocol/proto/playedstate.proto new file mode 100644 index 00000000..fefce00f --- /dev/null +++ b/protocol/proto/playedstate.proto @@ -0,0 +1,40 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify_playedstate.proto; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playedstate.proto"; + +message PlayedStateItem { + optional Type type = 1; + optional bytes uri = 2; + optional int64 client_timestamp = 3; + optional int32 play_position = 4; + optional bool played = 5; + optional int32 duration = 6; +} + +message PlayedState { + optional int64 server_timestamp = 1; + optional bool truncated = 2; + repeated PlayedStateItem state = 3; +} + +message PlayedStateItemList { + repeated PlayedStateItem state = 1; +} + +message ContentId { + optional Type type = 1; + optional bytes uri = 2; +} + +message ContentIdList { + repeated ContentId contentIds = 1; +} + +enum Type { + EPISODE = 0; +} diff --git a/protocol/proto/player.proto b/protocol/proto/player.proto new file mode 100644 index 00000000..dfe5e5ab --- /dev/null +++ b/protocol/proto/player.proto @@ -0,0 +1,211 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.connectstate; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.connectstate.model"; + +message PlayerState { + int64 timestamp = 1; + string context_uri = 2; + string context_url = 3; + Restrictions context_restrictions = 4; + PlayOrigin play_origin = 5; + ContextIndex index = 6; + ProvidedTrack track = 7; + string playback_id = 8; + double playback_speed = 9; + int64 position_as_of_timestamp = 10; + int64 duration = 11; + bool is_playing = 12; + bool is_paused = 13; + bool is_buffering = 14; + bool is_system_initiated = 15; + ContextPlayerOptions options = 16; + Restrictions restrictions = 17; + Suppressions suppressions = 18; + repeated ProvidedTrack prev_tracks = 19; + repeated ProvidedTrack next_tracks = 20; + map context_metadata = 21; + map page_metadata = 22; + string session_id = 23; + string queue_revision = 24; + int64 position = 25; + string entity_uri = 26; + repeated ProvidedTrack reverse = 27; + repeated ProvidedTrack future = 28; + string audio_stream = 29; + bool is_optional = 30 [deprecated = true]; + int64 bitrate = 31 [deprecated = true]; + PlaybackQuality playback_quality = 32; +} + +message ProvidedTrack { + string uri = 1; + string uid = 2; + map metadata = 3; + repeated string removed = 4; + repeated string blocked = 5; + string provider = 6; + Restrictions restrictions = 7; + string album_uri = 8; + repeated string disallow_reasons = 9; + string artist_uri = 10; + repeated string disallow_undecided = 11; +} + +message ContextIndex { + uint32 page = 1; + uint32 track = 2; +} + +message Restrictions { + repeated string disallow_pausing_reasons = 1; + repeated string disallow_resuming_reasons = 2; + repeated string disallow_seeking_reasons = 3; + repeated string disallow_peeking_prev_reasons = 4; + repeated string disallow_peeking_next_reasons = 5; + repeated string disallow_skipping_prev_reasons = 6; + repeated string disallow_skipping_next_reasons = 7; + repeated string disallow_toggling_repeat_context_reasons = 8; + repeated string disallow_toggling_repeat_track_reasons = 9; + repeated string disallow_toggling_shuffle_reasons = 10; + repeated string disallow_set_queue_reasons = 11; + repeated string disallow_interrupting_playback_reasons = 12; + repeated string disallow_transferring_playback_reasons = 13; + repeated string disallow_remote_control_reasons = 14; + repeated string disallow_inserting_into_next_tracks_reasons = 15; + repeated string disallow_inserting_into_context_tracks_reasons = 16; + repeated string disallow_reordering_in_next_tracks_reasons = 17; + repeated string disallow_reordering_in_context_tracks_reasons = 18; + repeated string disallow_removing_from_next_tracks_reasons = 19; + repeated string disallow_removing_from_context_tracks_reasons = 20; + repeated string disallow_updating_context_reasons = 21; + repeated string disallow_playing_reasons = 22; + repeated string disallow_stopping_reasons = 23; +} + +message PlayOrigin { + string feature_identifier = 1; + string feature_version = 2; + string view_uri = 3; + string external_referrer = 4; + string referrer_identifier = 5; + string device_identifier = 6; + repeated string feature_classes = 7; +} + +message ContextPlayerOptions { + bool shuffling_context = 1; + bool repeating_context = 2; + bool repeating_track = 3; +} + +message Suppressions { + repeated string providers = 1; +} + +message InstrumentationParams { + repeated string interaction_ids = 6; + repeated string page_instance_ids = 7; +} + +message Playback { + int64 timestamp = 1; + int32 position_as_of_timestamp = 2; + double playback_speed = 3; + bool is_paused = 4; + ContextTrack current_track = 5; +} + +message Queue { + repeated ContextTrack tracks = 1; + bool is_playing_queue = 2; +} + +message Session { + PlayOrigin play_origin = 1; + Context context = 2; + string current_uid = 3; + ContextPlayerOptionOverrides option_overrides = 4; + Suppressions suppressions = 5; + InstrumentationParams instrumentation_params = 6; +} + +message TransferState { + ContextPlayerOptions options = 1; + Playback playback = 2; + Session current_session = 3; + Queue queue = 4; +} + +message ContextTrack { + string uri = 1; + string uid = 2; + bytes gid = 3; + map metadata = 4; +} + +message ContextPlayerOptionOverrides { + bool shuffling_context = 1; + bool repeating_context = 2; + bool repeating_track = 3; +} + +message Context { + string uri = 1; + string url = 2; + map metadata = 3; + Restrictions restrictions = 4; + repeated ContextPage pages = 5; + bool loading = 6; +} + +message ContextPage { + string page_url = 1; + string next_page_url = 2; + map metadata = 3; + repeated ContextTrack tracks = 4; + bool loading = 5; +} + +message PlayerQueue { + string revision = 1; + repeated ProvidedTrack next_tracks = 2; + repeated ProvidedTrack prev_tracks = 3; + ProvidedTrack track = 4; +} + +message PlaybackQuality { + BitrateLevel bitrate_level = 1; + BitrateStrategy strategy = 2; + BitrateLevel target_bitrate_level = 3; + bool target_bitrate_available = 4; + HiFiStatus hifi_status = 5; +} + +enum BitrateLevel { + unknown_bitrate_level = 0; + low = 1; + normal = 2; + high = 3; + very_high = 4; + hifi = 5; +} + +enum BitrateStrategy { + unknown_strategy = 0; + best_matching = 1; + backend_advised = 2; + offlined_file = 3; + cached_file = 4; + local_file = 5; +} + +enum HiFiStatus { + none = 0; + off = 1; + on = 2; +} diff --git a/protocol/proto/player_license.proto b/protocol/proto/player_license.proto new file mode 100644 index 00000000..3d0e905d --- /dev/null +++ b/protocol/proto/player_license.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message PlayerLicense { + optional string identifier = 1; +} diff --git a/protocol/proto/player_model.proto b/protocol/proto/player_model.proto new file mode 100644 index 00000000..6856ca0d --- /dev/null +++ b/protocol/proto/player_model.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "logging_params.proto"; + +option optimize_for = CODE_SIZE; + +message PlayerModel { + optional bool is_paused = 1; + optional uint64 hash = 2; + optional LoggingParams logging_params = 3; + + optional StartReason start_reason = 4; + enum StartReason { + REMOTE_TRANSFER = 0; + COMEBACK = 1; + PLAY_CONTEXT = 2; + PLAY_SPECIFIC_TRACK = 3; + TRACK_FINISHED = 4; + SKIP_TO_NEXT_TRACK = 5; + SKIP_TO_PREV_TRACK = 6; + ERROR = 7; + IGNORED = 8; + UNKNOWN = 9; + } +} diff --git a/protocol/proto/playlist4_external.proto b/protocol/proto/playlist4_external.proto new file mode 100644 index 00000000..2a7b44b9 --- /dev/null +++ b/protocol/proto/playlist4_external.proto @@ -0,0 +1,292 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist4.proto; + +import "playlist_permission.proto"; + +option optimize_for = CODE_SIZE; +option java_outer_classname = "Playlist4ApiProto"; +option java_package = "com.spotify.playlist4.proto"; + +message Item { + required string uri = 1; + optional ItemAttributes attributes = 2; +} + +message MetaItem { + optional bytes revision = 1; + optional ListAttributes attributes = 2; + optional int32 length = 3; + optional int64 timestamp = 4; + optional string owner_username = 5; + optional bool abuse_reporting_enabled = 6; + optional spotify.playlist_permission.proto.Capabilities capabilities = 7; +} + +message ListItems { + required int32 pos = 1; + required bool truncated = 2; + repeated Item items = 3; + repeated MetaItem meta_items = 4; +} + +message FormatListAttribute { + optional string key = 1; + optional string value = 2; +} + +message PictureSize { + optional string target_name = 1; + optional string url = 2; +} + +message ListAttributes { + optional string name = 1; + optional string description = 2; + optional bytes picture = 3; + optional bool collaborative = 4; + optional string pl3_version = 5; + optional bool deleted_by_owner = 6; + optional string client_id = 10; + optional string format = 11; + repeated FormatListAttribute format_attributes = 12; + repeated PictureSize picture_size = 13; +} + +message ItemAttributes { + optional string added_by = 1; + optional int64 timestamp = 2; + optional int64 seen_at = 9; + optional bool public = 10; + repeated FormatListAttribute format_attributes = 11; + optional bytes item_id = 12; +} + +message Add { + optional int32 from_index = 1; + repeated Item items = 2; + optional bool add_last = 4; + optional bool add_first = 5; +} + +message Rem { + optional int32 from_index = 1; + optional int32 length = 2; + repeated Item items = 3; + optional bool items_as_key = 7; +} + +message Mov { + required int32 from_index = 1; + required int32 length = 2; + required int32 to_index = 3; +} + +message ItemAttributesPartialState { + required ItemAttributes values = 1; + repeated ItemAttributeKind no_value = 2; +} + +message ListAttributesPartialState { + required ListAttributes values = 1; + repeated ListAttributeKind no_value = 2; +} + +message UpdateItemAttributes { + required int32 index = 1; + required ItemAttributesPartialState new_attributes = 2; + optional ItemAttributesPartialState old_attributes = 3; +} + +message UpdateListAttributes { + required ListAttributesPartialState new_attributes = 1; + optional ListAttributesPartialState old_attributes = 2; +} + +message Op { + required Kind kind = 1; + enum Kind { + KIND_UNKNOWN = 0; + ADD = 2; + REM = 3; + MOV = 4; + UPDATE_ITEM_ATTRIBUTES = 5; + UPDATE_LIST_ATTRIBUTES = 6; + } + + optional Add add = 2; + optional Rem rem = 3; + optional Mov mov = 4; + optional UpdateItemAttributes update_item_attributes = 5; + optional UpdateListAttributes update_list_attributes = 6; +} + +message OpList { + repeated Op ops = 1; +} + +message ChangeInfo { + optional string user = 1; + optional int64 timestamp = 2; + optional bool admin = 3; + optional bool undo = 4; + optional bool redo = 5; + optional bool merge = 6; + optional bool compressed = 7; + optional bool migration = 8; + optional int32 split_id = 9; + optional SourceInfo source = 10; +} + +message SourceInfo { + optional Client client = 1; + enum Client { + CLIENT_UNKNOWN = 0; + NATIVE_HERMES = 1; + CLIENT = 2; + PYTHON = 3; + JAVA = 4; + WEBPLAYER = 5; + LIBSPOTIFY = 6; + } + + optional string app = 3; + optional string source = 4; + optional string version = 5; +} + +message Delta { + optional bytes base_version = 1; + repeated Op ops = 2; + optional ChangeInfo info = 4; +} + +message Diff { + required bytes from_revision = 1; + repeated Op ops = 2; + required bytes to_revision = 3; +} + +message ListChanges { + optional bytes base_revision = 1; + repeated Delta deltas = 2; + optional bool want_resulting_revisions = 3; + optional bool want_sync_result = 4; + repeated int64 nonces = 6; +} + +message SelectedListContent { + optional bytes revision = 1; + optional int32 length = 2; + optional ListAttributes attributes = 3; + optional ListItems contents = 5; + optional Diff diff = 6; + optional Diff sync_result = 7; + repeated bytes resulting_revisions = 8; + optional bool multiple_heads = 9; + optional bool up_to_date = 10; + repeated int64 nonces = 14; + optional int64 timestamp = 15; + optional string owner_username = 16; + optional bool abuse_reporting_enabled = 17; + optional spotify.playlist_permission.proto.Capabilities capabilities = 18; + repeated GeoblockBlockingType geoblock = 19; +} + +message CreateListReply { + required string uri = 1; + optional bytes revision = 2; +} + +message PlaylistV1UriRequest { + repeated string v2_uris = 1; +} + +message PlaylistV1UriReply { + map v2_uri_to_v1_uri = 1; +} + +message ListUpdateRequest { + optional bytes base_revision = 1; + optional ListAttributes attributes = 2; + repeated Item items = 3; + optional ChangeInfo info = 4; +} + +message RegisterPlaylistImageRequest { + optional string upload_token = 1; +} + +message RegisterPlaylistImageResponse { + optional bytes picture = 1; +} + +message ResolvedPersonalizedPlaylist { + optional string uri = 1; + optional string tag = 2; +} + +message PlaylistUriResolverResponse { + repeated ResolvedPersonalizedPlaylist resolved_playlists = 1; +} + +message SubscribeRequest { + repeated bytes uris = 1; +} + +message UnsubscribeRequest { + repeated bytes uris = 1; +} + +message PlaylistModificationInfo { + optional bytes uri = 1; + optional bytes new_revision = 2; + optional bytes parent_revision = 3; + repeated Op ops = 4; +} + +message RootlistModificationInfo { + optional bytes new_revision = 1; + optional bytes parent_revision = 2; + repeated Op ops = 3; +} + +message FollowerUpdate { + optional string uri = 1; + optional string username = 2; + optional bool is_following = 3; + optional uint64 timestamp = 4; +} + +enum ListAttributeKind { + LIST_UNKNOWN = 0; + LIST_NAME = 1; + LIST_DESCRIPTION = 2; + LIST_PICTURE = 3; + LIST_COLLABORATIVE = 4; + LIST_PL3_VERSION = 5; + LIST_DELETED_BY_OWNER = 6; + LIST_CLIENT_ID = 10; + LIST_FORMAT = 11; + LIST_FORMAT_ATTRIBUTES = 12; + LIST_PICTURE_SIZE = 13; +} + +enum ItemAttributeKind { + ITEM_UNKNOWN = 0; + ITEM_ADDED_BY = 1; + ITEM_TIMESTAMP = 2; + ITEM_SEEN_AT = 9; + ITEM_PUBLIC = 10; + ITEM_FORMAT_ATTRIBUTES = 11; + ITEM_ID = 12; +} + +enum GeoblockBlockingType { + GEOBLOCK_BLOCKING_TYPE_UNSPECIFIED = 0; + GEOBLOCK_BLOCKING_TYPE_TITLE = 1; + GEOBLOCK_BLOCKING_TYPE_DESCRIPTION = 2; + GEOBLOCK_BLOCKING_TYPE_IMAGE = 3; +} diff --git a/protocol/proto/playlist4changes.proto b/protocol/proto/playlist4changes.proto deleted file mode 100644 index 6b424b71..00000000 --- a/protocol/proto/playlist4changes.proto +++ /dev/null @@ -1,87 +0,0 @@ -syntax = "proto2"; - -import "playlist4ops.proto"; -import "playlist4meta.proto"; -import "playlist4content.proto"; -import "playlist4issues.proto"; - -message ChangeInfo { - optional string user = 0x1; - optional int32 timestamp = 0x2; - optional bool admin = 0x3; - optional bool undo = 0x4; - optional bool redo = 0x5; - optional bool merge = 0x6; - optional bool compressed = 0x7; - optional bool migration = 0x8; -} - -message Delta { - optional bytes base_version = 0x1; - repeated Op ops = 0x2; - optional ChangeInfo info = 0x4; -} - -message Merge { - optional bytes base_version = 0x1; - optional bytes merge_version = 0x2; - optional ChangeInfo info = 0x4; -} - -message ChangeSet { - optional Kind kind = 0x1; - enum Kind { - KIND_UNKNOWN = 0x0; - DELTA = 0x2; - MERGE = 0x3; - } - optional Delta delta = 0x2; - optional Merge merge = 0x3; -} - -message RevisionTaggedChangeSet { - optional bytes revision = 0x1; - optional ChangeSet change_set = 0x2; -} - -message Diff { - optional bytes from_revision = 0x1; - repeated Op ops = 0x2; - optional bytes to_revision = 0x3; -} - -message ListDump { - optional bytes latestRevision = 0x1; - optional int32 length = 0x2; - optional ListAttributes attributes = 0x3; - optional ListChecksum checksum = 0x4; - optional ListItems contents = 0x5; - repeated Delta pendingDeltas = 0x7; -} - -message ListChanges { - optional bytes baseRevision = 0x1; - repeated Delta deltas = 0x2; - optional bool wantResultingRevisions = 0x3; - optional bool wantSyncResult = 0x4; - optional ListDump dump = 0x5; - repeated int32 nonces = 0x6; -} - -message SelectedListContent { - optional bytes revision = 0x1; - optional int32 length = 0x2; - optional ListAttributes attributes = 0x3; - optional ListChecksum checksum = 0x4; - optional ListItems contents = 0x5; - optional Diff diff = 0x6; - optional Diff syncResult = 0x7; - repeated bytes resultingRevisions = 0x8; - optional bool multipleHeads = 0x9; - optional bool upToDate = 0xa; - repeated ClientResolveAction resolveAction = 0xc; - repeated ClientIssue issues = 0xd; - repeated int32 nonces = 0xe; - optional string owner_username =0x10; -} - diff --git a/protocol/proto/playlist4content.proto b/protocol/proto/playlist4content.proto deleted file mode 100644 index 50d197fa..00000000 --- a/protocol/proto/playlist4content.proto +++ /dev/null @@ -1,37 +0,0 @@ -syntax = "proto2"; - -import "playlist4meta.proto"; -import "playlist4issues.proto"; - -message Item { - optional string uri = 0x1; - optional ItemAttributes attributes = 0x2; -} - -message ListItems { - optional int32 pos = 0x1; - optional bool truncated = 0x2; - repeated Item items = 0x3; -} - -message ContentRange { - optional int32 pos = 0x1; - optional int32 length = 0x2; -} - -message ListContentSelection { - optional bool wantRevision = 0x1; - optional bool wantLength = 0x2; - optional bool wantAttributes = 0x3; - optional bool wantChecksum = 0x4; - optional bool wantContent = 0x5; - optional ContentRange contentRange = 0x6; - optional bool wantDiff = 0x7; - optional bytes baseRevision = 0x8; - optional bytes hintRevision = 0x9; - optional bool wantNothingIfUpToDate = 0xa; - optional bool wantResolveAction = 0xc; - repeated ClientIssue issues = 0xd; - repeated ClientResolveAction resolveAction = 0xe; -} - diff --git a/protocol/proto/playlist4issues.proto b/protocol/proto/playlist4issues.proto deleted file mode 100644 index 3808d532..00000000 --- a/protocol/proto/playlist4issues.proto +++ /dev/null @@ -1,43 +0,0 @@ -syntax = "proto2"; - -message ClientIssue { - optional Level level = 0x1; - enum Level { - LEVEL_UNKNOWN = 0x0; - LEVEL_DEBUG = 0x1; - LEVEL_INFO = 0x2; - LEVEL_NOTICE = 0x3; - LEVEL_WARNING = 0x4; - LEVEL_ERROR = 0x5; - } - optional Code code = 0x2; - enum Code { - CODE_UNKNOWN = 0x0; - CODE_INDEX_OUT_OF_BOUNDS = 0x1; - CODE_VERSION_MISMATCH = 0x2; - CODE_CACHED_CHANGE = 0x3; - CODE_OFFLINE_CHANGE = 0x4; - CODE_CONCURRENT_CHANGE = 0x5; - } - optional int32 repeatCount = 0x3; -} - -message ClientResolveAction { - optional Code code = 0x1; - enum Code { - CODE_UNKNOWN = 0x0; - CODE_NO_ACTION = 0x1; - CODE_RETRY = 0x2; - CODE_RELOAD = 0x3; - CODE_DISCARD_LOCAL_CHANGES = 0x4; - CODE_SEND_DUMP = 0x5; - CODE_DISPLAY_ERROR_MESSAGE = 0x6; - } - optional Initiator initiator = 0x2; - enum Initiator { - INITIATOR_UNKNOWN = 0x0; - INITIATOR_SERVER = 0x1; - INITIATOR_CLIENT = 0x2; - } -} - diff --git a/protocol/proto/playlist4meta.proto b/protocol/proto/playlist4meta.proto deleted file mode 100644 index 4c22a9f0..00000000 --- a/protocol/proto/playlist4meta.proto +++ /dev/null @@ -1,52 +0,0 @@ -syntax = "proto2"; - -message ListChecksum { - optional int32 version = 0x1; - optional bytes sha1 = 0x4; -} - -message DownloadFormat { - optional Codec codec = 0x1; - enum Codec { - CODEC_UNKNOWN = 0x0; - OGG_VORBIS = 0x1; - FLAC = 0x2; - MPEG_1_LAYER_3 = 0x3; - } -} - -message ListAttributes { - optional string name = 0x1; - optional string description = 0x2; - optional bytes picture = 0x3; - optional bool collaborative = 0x4; - optional string pl3_version = 0x5; - optional bool deleted_by_owner = 0x6; - optional bool restricted_collaborative = 0x7; - optional int64 deprecated_client_id = 0x8; - optional bool public_starred = 0x9; - optional string client_id = 0xa; -} - -message ItemAttributes { - optional string added_by = 0x1; - optional int64 timestamp = 0x2; - optional string message = 0x3; - optional bool seen = 0x4; - optional int64 download_count = 0x5; - optional DownloadFormat download_format = 0x6; - optional string sevendigital_id = 0x7; - optional int64 sevendigital_left = 0x8; - optional int64 seen_at = 0x9; - optional bool public = 0xa; -} - -message StringAttribute { - optional string key = 0x1; - optional string value = 0x2; -} - -message StringAttributes { - repeated StringAttribute attribute = 0x1; -} - diff --git a/protocol/proto/playlist4ops.proto b/protocol/proto/playlist4ops.proto deleted file mode 100644 index dbbfcaa9..00000000 --- a/protocol/proto/playlist4ops.proto +++ /dev/null @@ -1,103 +0,0 @@ -syntax = "proto2"; - -import "playlist4meta.proto"; -import "playlist4content.proto"; - -message Add { - optional int32 fromIndex = 0x1; - repeated Item items = 0x2; - optional ListChecksum list_checksum = 0x3; - optional bool addLast = 0x4; - optional bool addFirst = 0x5; -} - -message Rem { - optional int32 fromIndex = 0x1; - optional int32 length = 0x2; - repeated Item items = 0x3; - optional ListChecksum list_checksum = 0x4; - optional ListChecksum items_checksum = 0x5; - optional ListChecksum uris_checksum = 0x6; - optional bool itemsAsKey = 0x7; -} - -message Mov { - optional int32 fromIndex = 0x1; - optional int32 length = 0x2; - optional int32 toIndex = 0x3; - optional ListChecksum list_checksum = 0x4; - optional ListChecksum items_checksum = 0x5; - optional ListChecksum uris_checksum = 0x6; -} - -message ItemAttributesPartialState { - optional ItemAttributes values = 0x1; - repeated ItemAttributeKind no_value = 0x2; - - enum ItemAttributeKind { - ITEM_UNKNOWN = 0x0; - ITEM_ADDED_BY = 0x1; - ITEM_TIMESTAMP = 0x2; - ITEM_MESSAGE = 0x3; - ITEM_SEEN = 0x4; - ITEM_DOWNLOAD_COUNT = 0x5; - ITEM_DOWNLOAD_FORMAT = 0x6; - ITEM_SEVENDIGITAL_ID = 0x7; - ITEM_SEVENDIGITAL_LEFT = 0x8; - ITEM_SEEN_AT = 0x9; - ITEM_PUBLIC = 0xa; - } -} - -message ListAttributesPartialState { - optional ListAttributes values = 0x1; - repeated ListAttributeKind no_value = 0x2; - - enum ListAttributeKind { - LIST_UNKNOWN = 0x0; - LIST_NAME = 0x1; - LIST_DESCRIPTION = 0x2; - LIST_PICTURE = 0x3; - LIST_COLLABORATIVE = 0x4; - LIST_PL3_VERSION = 0x5; - LIST_DELETED_BY_OWNER = 0x6; - LIST_RESTRICTED_COLLABORATIVE = 0x7; - } -} - -message UpdateItemAttributes { - optional int32 index = 0x1; - optional ItemAttributesPartialState new_attributes = 0x2; - optional ItemAttributesPartialState old_attributes = 0x3; - optional ListChecksum list_checksum = 0x4; - optional ListChecksum old_attributes_checksum = 0x5; -} - -message UpdateListAttributes { - optional ListAttributesPartialState new_attributes = 0x1; - optional ListAttributesPartialState old_attributes = 0x2; - optional ListChecksum list_checksum = 0x3; - optional ListChecksum old_attributes_checksum = 0x4; -} - -message Op { - optional Kind kind = 0x1; - enum Kind { - KIND_UNKNOWN = 0x0; - ADD = 0x2; - REM = 0x3; - MOV = 0x4; - UPDATE_ITEM_ATTRIBUTES = 0x5; - UPDATE_LIST_ATTRIBUTES = 0x6; - } - optional Add add = 0x2; - optional Rem rem = 0x3; - optional Mov mov = 0x4; - optional UpdateItemAttributes update_item_attributes = 0x5; - optional UpdateListAttributes update_list_attributes = 0x6; -} - -message OpList { - repeated Op ops = 0x1; -} - diff --git a/protocol/proto/playlist_annotate3.proto b/protocol/proto/playlist_annotate3.proto new file mode 100644 index 00000000..3b6b919f --- /dev/null +++ b/protocol/proto/playlist_annotate3.proto @@ -0,0 +1,41 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto2"; + +package spotify_playlist_annotate3.proto; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist_annotate3.proto"; + +message TakedownRequest { + optional AbuseReportState abuse_report_state = 1; +} + +message AnnotateRequest { + optional string description = 1; + optional string image_uri = 2; +} + +message TranscodedPicture { + optional string target_name = 1; + optional string uri = 2; +} + +message PlaylistAnnotation { + optional string description = 1; + optional string picture = 2; + optional RenderFeatures deprecated_render_features = 3 [default = NORMAL_FEATURES, deprecated = true]; + repeated TranscodedPicture transcoded_picture = 4; + optional bool is_abuse_reporting_enabled = 6 [default = true]; + optional AbuseReportState abuse_report_state = 7 [default = OK]; +} + +enum RenderFeatures { + NORMAL_FEATURES = 1; + EXTENDED_FEATURES = 2; +} + +enum AbuseReportState { + OK = 0; + TAKEN_DOWN = 1; +} diff --git a/protocol/proto/playlist_contains_request.proto b/protocol/proto/playlist_contains_request.proto new file mode 100644 index 00000000..072d5379 --- /dev/null +++ b/protocol/proto/playlist_contains_request.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "contains_request.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistContainsRequest { + string uri = 1; + playlist.cosmos.proto.ContainsRequest request = 2; +} + +message PlaylistContainsResponse { + ResponseStatus status = 1; + playlist.cosmos.proto.ContainsResponse response = 2; +} diff --git a/protocol/proto/playlist_folder_state.proto b/protocol/proto/playlist_folder_state.proto new file mode 100644 index 00000000..a2d32d71 --- /dev/null +++ b/protocol/proto/playlist_folder_state.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message FolderMetadata { + optional string id = 1; + optional string name = 2; + optional uint32 num_folders = 3; + optional uint32 num_playlists = 4; + optional uint32 num_recursive_folders = 5; + optional uint32 num_recursive_playlists = 6; + optional string link = 7; +} diff --git a/protocol/proto/playlist_get_request.proto b/protocol/proto/playlist_get_request.proto new file mode 100644 index 00000000..7e6dd3f0 --- /dev/null +++ b/protocol/proto/playlist_get_request.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "policy/playlist_request_decoration_policy.proto"; +import "playlist_query.proto"; +import "playlist_request.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistGetRequest { + string uri = 1; + PlaylistQuery query = 2; + playlist.cosmos.proto.PlaylistRequestDecorationPolicy policy = 3; +} + +message PlaylistGetResponse { + ResponseStatus status = 1; + playlist.cosmos.playlist_request.proto.Response data = 2; +} diff --git a/protocol/proto/playlist_members_request.proto b/protocol/proto/playlist_members_request.proto new file mode 100644 index 00000000..d5bd9b98 --- /dev/null +++ b/protocol/proto/playlist_members_request.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "members_request.proto"; +import "members_response.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistMembersResponse { + ResponseStatus status = 1; + playlist.cosmos.proto.PlaylistMembersResponse response = 2; +} diff --git a/protocol/proto/playlist_modification_request.proto b/protocol/proto/playlist_modification_request.proto new file mode 100644 index 00000000..2bdb0146 --- /dev/null +++ b/protocol/proto/playlist_modification_request.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "modification_request.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistModificationRequest { + string uri = 1; + playlist.cosmos.proto.ModificationRequest request = 2; +} + +message PlaylistModificationResponse { + ResponseStatus status = 1; +} diff --git a/protocol/proto/playlist_offline_request.proto b/protocol/proto/playlist_offline_request.proto new file mode 100644 index 00000000..e0ab6312 --- /dev/null +++ b/protocol/proto/playlist_offline_request.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "playlist_query.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistOfflineRequest { + string uri = 1; + PlaylistQuery query = 2; + PlaylistOfflineAction action = 3; +} + +message PlaylistOfflineResponse { + ResponseStatus status = 1; +} + +enum PlaylistOfflineAction { + NONE = 0; + SET_AS_AVAILABLE_OFFLINE = 1; + REMOVE_AS_AVAILABLE_OFFLINE = 2; +} diff --git a/protocol/proto/playlist_permission.proto b/protocol/proto/playlist_permission.proto new file mode 100644 index 00000000..96e9c06d --- /dev/null +++ b/protocol/proto/playlist_permission.proto @@ -0,0 +1,100 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist_permission.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message Permission { + optional bytes revision = 1; + optional PermissionLevel permission_level = 2; +} + +message Capabilities { + optional bool can_view = 1; + optional bool can_administrate_permissions = 2; + repeated PermissionLevel grantable_level = 3; + optional bool can_edit_metadata = 4; + optional bool can_edit_items = 5; + optional bool can_cancel_membership = 6; +} + +message CapabilitiesMultiRequest { + repeated CapabilitiesRequest request = 1; + optional string fallback_username = 2; + optional string fallback_user_id = 3; + optional string fallback_uri = 4; +} + +message CapabilitiesRequest { + optional string username = 1; + optional string user_id = 2; + optional string uri = 3; + optional bool user_is_owner = 4; +} + +message CapabilitiesMultiResponse { + repeated CapabilitiesResponse response = 1; +} + +message CapabilitiesResponse { + optional ResponseStatus status = 1; + optional Capabilities capabilities = 2; +} + +message SetPermissionLevelRequest { + optional PermissionLevel permission_level = 1; +} + +message SetPermissionResponse { + optional Permission resulting_permission = 1; +} + +message GetMemberPermissionsResponse { + map member_permissions = 1; +} + +message Permissions { + optional Permission base_permission = 1; +} + +message PermissionState { + optional Permissions permissions = 1; + optional Capabilities capabilities = 2; + optional bool is_private = 3; + optional bool is_collaborative = 4; +} + +message PermissionStatePub { + optional PermissionState permission_state = 1; +} + +message PermissionGrantOptions { + optional Permission permission = 1; + optional int64 ttl_ms = 2; +} + +message PermissionGrant { + optional string token = 1; + optional PermissionGrantOptions permission_grant_options = 2; +} + +message ClaimPermissionGrantResponse { + optional Permission user_permission = 1; + optional Capabilities capabilities = 2; +} + +message ResponseStatus { + optional int32 status_code = 1; + optional string status_message = 2; +} + +enum PermissionLevel { + UNKNOWN = 0; + BLOCKED = 1; + VIEWER = 2; + CONTRIBUTOR = 3; +} diff --git a/protocol/proto/playlist_play_request.proto b/protocol/proto/playlist_play_request.proto new file mode 100644 index 00000000..032b2b2a --- /dev/null +++ b/protocol/proto/playlist_play_request.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "es_context.proto"; +import "es_play_options.proto"; +import "es_logging_params.proto"; +import "es_prepare_play_options.proto"; +import "es_play_origin.proto"; +import "playlist_query.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistPlayRequest { + PlaylistQuery playlist_query = 1; + player.esperanto.proto.Context context = 2; + player.esperanto.proto.PlayOptions play_options = 3; + player.esperanto.proto.LoggingParams logging_params = 4; + player.esperanto.proto.PreparePlayOptions prepare_play_options = 5; + player.esperanto.proto.PlayOrigin play_origin = 6; +} + +message PlaylistPlayResponse { + ResponseStatus status = 1; +} diff --git a/protocol/proto/playlist_playback_request.proto b/protocol/proto/playlist_playback_request.proto new file mode 100644 index 00000000..8cd20257 --- /dev/null +++ b/protocol/proto/playlist_playback_request.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message PlaybackResponse { + bool success = 1; +} diff --git a/protocol/proto/playlist_playlist_state.proto b/protocol/proto/playlist_playlist_state.proto new file mode 100644 index 00000000..5663252c --- /dev/null +++ b/protocol/proto/playlist_playlist_state.proto @@ -0,0 +1,52 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "metadata/extension.proto"; +import "metadata/image_group.proto"; +import "playlist_user_state.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message FormatListAttribute { + optional string key = 1; + optional string value = 2; +} + +message Allows { + optional bool can_insert = 1; + optional bool can_remove = 2; +} + +message PlaylistMetadata { + optional string link = 1; + optional string name = 2; + optional User owner = 3; + optional bool owned_by_self = 4; + optional bool collaborative = 5; + optional uint32 total_length = 6; + optional string description = 7; + optional cosmos_util.proto.ImageGroup pictures = 8; + optional bool followed = 9; + optional bool published = 10; + optional bool browsable_offline = 11; + optional bool description_from_annotate = 12; + optional bool picture_from_annotate = 13; + optional string format_list_type = 14; + repeated FormatListAttribute format_list_attributes = 15; + optional bool can_report_annotation_abuse = 16; + optional bool is_loaded = 17; + optional Allows allows = 18; + optional string load_state = 19; + optional User made_for = 20; + repeated cosmos_util.proto.Extension extension = 21; +} + +message PlaylistOfflineState { + optional string offline = 1; + optional uint32 sync_progress = 2; +} diff --git a/protocol/proto/playlist_query.proto b/protocol/proto/playlist_query.proto new file mode 100644 index 00000000..afd97614 --- /dev/null +++ b/protocol/proto/playlist_query.proto @@ -0,0 +1,63 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistRange { + int32 start = 1; + int32 length = 2; +} + +message PlaylistQuery { + repeated BoolPredicate bool_predicates = 1; + enum BoolPredicate { + NO_FILTER = 0; + AVAILABLE = 1; + AVAILABLE_OFFLINE = 2; + ARTIST_NOT_BANNED = 3; + NOT_BANNED = 4; + NOT_EXPLICIT = 5; + NOT_EPISODE = 6; + } + + string text_filter = 2; + + SortBy sort_by = 3; + enum SortBy { + NO_SORT = 0; + ALBUM_ARTIST_NAME_ASC = 1; + ALBUM_ARTIST_NAME_DESC = 2; + TRACK_NUMBER_ASC = 3; + TRACK_NUMBER_DESC = 4; + DISC_NUMBER_ASC = 5; + DISC_NUMBER_DESC = 6; + ALBUM_NAME_ASC = 7; + ALBUM_NAME_DESC = 8; + ARTIST_NAME_ASC = 9; + ARTIST_NAME_DESC = 10; + NAME_ASC = 11; + NAME_DESC = 12; + ADD_TIME_ASC = 13; + ADD_TIME_DESC = 14; + } + + PlaylistRange range = 4; + int32 update_throttling_ms = 5; + bool group = 6; + PlaylistSourceRestriction source_restriction = 7; + bool show_unavailable = 8; + bool always_show_windowed = 9; + bool load_recommendations = 10; +} + +enum PlaylistSourceRestriction { + NO_RESTRICTION = 0; + RESTRICT_SOURCE_TO_50 = 1; + RESTRICT_SOURCE_TO_500 = 2; +} diff --git a/protocol/proto/playlist_request.proto b/protocol/proto/playlist_request.proto new file mode 100644 index 00000000..52befb1f --- /dev/null +++ b/protocol/proto/playlist_request.proto @@ -0,0 +1,91 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist.cosmos.playlist_request.proto; + +import "collection/episode_collection_state.proto"; +import "metadata/episode_metadata.proto"; +import "played_state/track_played_state.proto"; +import "played_state/episode_played_state.proto"; +import "sync/episode_sync_state.proto"; +import "metadata/image_group.proto"; +import "on_demand_in_free_reason.proto"; +import "playlist_permission.proto"; +import "playlist_playlist_state.proto"; +import "playlist_track_state.proto"; +import "playlist_user_state.proto"; +import "metadata/track_metadata.proto"; + +option objc_class_prefix = "SPTPlaylistCosmosPlaylist"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message Item { + optional string header_field = 1; + optional uint32 add_time = 2; + optional cosmos.proto.User added_by = 3; + optional cosmos_util.proto.TrackMetadata track_metadata = 4; + optional cosmos.proto.TrackCollectionState track_collection_state = 5; + optional cosmos.proto.TrackOfflineState track_offline_state = 6; + optional string row_id = 7; + optional cosmos_util.proto.TrackPlayState track_play_state = 8; + repeated cosmos.proto.FormatListAttribute format_list_attributes = 9; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 10; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 11; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 12; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 13; + optional cosmos_util.proto.ImageGroup display_covers = 14; +} + +message Playlist { + optional cosmos.proto.PlaylistMetadata playlist_metadata = 1; + optional cosmos.proto.PlaylistOfflineState playlist_offline_state = 2; +} + +message RecommendationItem { + optional cosmos_util.proto.TrackMetadata track_metadata = 1; + optional cosmos.proto.TrackCollectionState track_collection_state = 2; + optional cosmos.proto.TrackOfflineState track_offline_state = 3; + optional cosmos_util.proto.TrackPlayState track_play_state = 4; +} + +message Collaborator { + optional cosmos.proto.User user = 1; + optional uint32 number_of_items = 2; + optional uint32 number_of_tracks = 3; + optional uint32 number_of_episodes = 4; + optional bool is_owner = 5; +} + +message Collaborators { + optional uint32 count = 1; + repeated Collaborator collaborator = 2; +} + +message Response { + repeated Item item = 1; + optional Playlist playlist = 2; + optional uint32 unfiltered_length = 3; + optional uint32 unranged_length = 4; + optional uint64 duration = 5; + optional bool loading_contents = 6; + optional uint64 last_modification = 7; + optional uint32 num_followers = 8; + optional bool playable = 9; + repeated RecommendationItem recommendations = 10; + optional bool has_explicit_content = 11; + optional bool contains_spotify_tracks = 12; + optional bool contains_episodes = 13; + optional bool only_contains_explicit = 14; + optional bool contains_audio_episodes = 15; + optional bool contains_tracks = 16; + optional bool is_on_demand_in_free = 17; + optional uint32 number_of_tracks = 18; + optional uint32 number_of_episodes = 19; + optional bool prefer_linear_playback = 20; + optional on_demand_set.proto.OnDemandInFreeReason on_demand_in_free_reason = 21; + optional Collaborators collaborators = 22; + optional playlist_permission.proto.Permission base_permission = 23; + optional playlist_permission.proto.Capabilities user_capabilities = 24; +} diff --git a/protocol/proto/playlist_set_base_permission_request.proto b/protocol/proto/playlist_set_base_permission_request.proto new file mode 100644 index 00000000..3e8e1838 --- /dev/null +++ b/protocol/proto/playlist_set_base_permission_request.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "playlist_set_permission_request.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistSetBasePermissionRequest { + string uri = 1; + playlist.cosmos.proto.SetBasePermissionRequest request = 2; +} + +message PlaylistSetBasePermissionResponse { + ResponseStatus status = 1; + playlist.cosmos.proto.SetBasePermissionResponse response = 2; +} diff --git a/protocol/proto/playlist_set_member_permission_request.proto b/protocol/proto/playlist_set_member_permission_request.proto new file mode 100644 index 00000000..d3d687a4 --- /dev/null +++ b/protocol/proto/playlist_set_member_permission_request.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistSetMemberPermissionResponse { + ResponseStatus status = 1; +} diff --git a/protocol/proto/playlist_set_permission_request.proto b/protocol/proto/playlist_set_permission_request.proto new file mode 100644 index 00000000..a410cc23 --- /dev/null +++ b/protocol/proto/playlist_set_permission_request.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "playlist_permission.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message SetBasePermissionRequest { + optional playlist_permission.proto.PermissionLevel permission_level = 1; + optional uint32 timeout_ms = 2; +} + +message SetBasePermissionResponse { + optional playlist_permission.proto.Permission base_permission = 1; +} diff --git a/protocol/proto/playlist_track_state.proto b/protocol/proto/playlist_track_state.proto new file mode 100644 index 00000000..cd55947f --- /dev/null +++ b/protocol/proto/playlist_track_state.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option objc_class_prefix = "SPTPlaylist"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message TrackCollectionState { + optional bool is_in_collection = 1; + optional bool can_add_to_collection = 2; + optional bool is_banned = 3; + optional bool can_ban = 4; +} + +message TrackOfflineState { + optional string offline = 1; +} diff --git a/protocol/proto/playlist_user_state.proto b/protocol/proto/playlist_user_state.proto new file mode 100644 index 00000000..86c07dee --- /dev/null +++ b/protocol/proto/playlist_user_state.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message User { + optional string link = 1; + optional string username = 2; + optional string display_name = 3; + optional string image_uri = 4; + optional string thumbnail_uri = 5; + optional int32 color = 6; +} diff --git a/protocol/proto/plugin.proto b/protocol/proto/plugin.proto new file mode 100644 index 00000000..c0e912ce --- /dev/null +++ b/protocol/proto/plugin.proto @@ -0,0 +1,141 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.offline.proto; + +import "google/protobuf/any.proto"; +import "extension_kind.proto"; +import "resource_type.proto"; + +option optimize_for = CODE_SIZE; + +message PluginRegistry { + repeated Entry plugins = 1; + message Entry { + string id = 1; + repeated LinkType supported_link_types = 2; + ResourceType resource_type = 3; + repeated extendedmetadata.ExtensionKind extension_kinds = 4; + } + + enum LinkType { + EMPTY = 0; + TRACK = 1; + EPISODE = 2; + } +} + +message PluginInit { + string id = 1; +} + +message TargetFormat { + int32 bitrate = 1; +} + +message Metadata { + Header header = 1; + message Header { + int32 status_code = 1; + bool is_empty = 2; + } + + google.protobuf.Any extension_data = 2; +} + +message IdentifyCommand { + Header header = 3; + message Header { + TargetFormat target_format = 1; + } + + repeated Query query = 4; + message Query { + string link = 1; + map metadata = 2; + } +} + +message IdentifyResponse { + map results = 1; + + message Result { + Status status = 1; + enum Status { + UNKNOWN = 0; + MISSING = 1; + COMPLETE = 2; + NOT_APPLICABLE = 3; + } + + int64 estimated_file_size = 2; + } +} + +message DownloadCommand { + string link = 1; + TargetFormat target_format = 2; + map metadata = 3; +} + +message DownloadResponse { + string link = 1; + bool complete = 2; + int64 file_size = 3; + int64 bytes_downloaded = 4; + + Error error = 5; + enum Error { + OK = 0; + TEMPORARY_ERROR = 1; + PERMANENT_ERROR = 2; + DISK_FULL = 3; + } +} + +message StopDownloadCommand { + string link = 1; +} + +message StopDownloadResponse { + +} + +message RemoveCommand { + Header header = 2; + message Header { + + } + + repeated Query query = 3; + message Query { + string link = 1; + } +} + +message RemoveResponse { + +} + +message PluginCommand { + string id = 1; + + oneof command { + IdentifyCommand identify = 2; + DownloadCommand download = 3; + RemoveCommand remove = 4; + StopDownloadCommand stop_download = 5; + } +} + +message PluginResponse { + string id = 1; + + oneof response { + IdentifyResponse identify = 2; + DownloadResponse download = 3; + RemoveResponse remove = 4; + StopDownloadResponse stop_download = 5; + } +} diff --git a/protocol/proto/podcast_ad_segments.proto b/protocol/proto/podcast_ad_segments.proto new file mode 100644 index 00000000..ebff7385 --- /dev/null +++ b/protocol/proto/podcast_ad_segments.proto @@ -0,0 +1,34 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.ads.formats; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastAdsProto"; +option java_package = "com.spotify.ads.formats.proto"; + +message PodcastAds { + repeated string file_ids = 1; + repeated string manifest_ids = 2; + repeated Segment segments = 3; +} + +message Segment { + Slot slot = 1; + int32 start_ms = 2; + int32 stop_ms = 3; +} + +enum Slot { + UNKNOWN = 0; + PODCAST_PREROLL = 1; + PODCAST_POSTROLL = 2; + PODCAST_MIDROLL_1 = 3; + PODCAST_MIDROLL_2 = 4; + PODCAST_MIDROLL_3 = 5; + PODCAST_MIDROLL_4 = 6; + PODCAST_MIDROLL_5 = 7; +} diff --git a/protocol/proto/podcast_cta_cards.proto b/protocol/proto/podcast_cta_cards.proto new file mode 100644 index 00000000..9cd4dfc6 --- /dev/null +++ b/protocol/proto/podcast_cta_cards.proto @@ -0,0 +1,9 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.context_track_exts.podcastctacards; + +message Card { + bool has_cards = 1; +} diff --git a/protocol/proto/podcast_paywalls_cosmos.proto b/protocol/proto/podcast_paywalls_cosmos.proto new file mode 100644 index 00000000..9b818137 --- /dev/null +++ b/protocol/proto/podcast_paywalls_cosmos.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_paywalls_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message PodcastPaywallsShowSubscriptionRequest { + string show_uri = 1; +} + +message PodcastPaywallsShowSubscriptionResponse { + bool is_user_subscribed = 1; +} diff --git a/protocol/proto/podcast_poll.proto b/protocol/proto/podcast_poll.proto new file mode 100644 index 00000000..60dc04c6 --- /dev/null +++ b/protocol/proto/podcast_poll.proto @@ -0,0 +1,48 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.polls; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PollMetadataProto"; +option java_package = "com.spotify.podcastcreatorinteractivity.v1"; + +message PodcastPoll { + Poll poll = 1; +} + +message Poll { + int32 id = 1; + string opening_date = 2; + string closing_date = 3; + int32 entity_timestamp_ms = 4; + string entity_uri = 5; + string name = 6; + string question = 7; + PollType type = 8; + repeated PollOption options = 9; + PollStatus status = 10; +} + +message PollOption { + string option = 1; + int32 total_votes = 2; + int32 poll_id = 3; + int32 option_id = 4; +} + +enum PollType { + MULTIPLE_CHOICE = 0; + SINGLE_CHOICE = 1; +} + +enum PollStatus { + DRAFT = 0; + SCHEDULED = 1; + LIVE = 2; + CLOSED = 3; + BLOCKED = 4; +} diff --git a/protocol/proto/podcast_qna.proto b/protocol/proto/podcast_qna.proto new file mode 100644 index 00000000..fca3ba55 --- /dev/null +++ b/protocol/proto/podcast_qna.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.qanda; + +import "google/protobuf/timestamp.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "QnAMetadataProto"; +option java_package = "com.spotify.podcastcreatorinteractivity.v1"; + +message PodcastQna { + Prompt prompt = 1; +} + +message Prompt { + int32 id = 1; + google.protobuf.Timestamp opening_date = 2; + google.protobuf.Timestamp closing_date = 3; + string text = 4; + QAndAStatus status = 5; +} + +enum QAndAStatus { + DRAFT = 0; + SCHEDULED = 1; + LIVE = 2; + CLOSED = 3; + DELETED = 4; +} diff --git a/protocol/proto/podcast_ratings.proto b/protocol/proto/podcast_ratings.proto new file mode 100644 index 00000000..c78c0282 --- /dev/null +++ b/protocol/proto/podcast_ratings.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.ratings; + +import "google/protobuf/timestamp.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "RatingsMetadataProto"; +option java_package = "com.spotify.podcastcreatorinteractivity.v1"; + +message Rating { + string user_id = 1; + string show_uri = 2; + int32 rating = 3; + google.protobuf.Timestamp rated_at = 4; +} + +message AverageRating { + double average = 1; + int64 total_ratings = 2; + bool show_average = 3; +} + +message PodcastRating { + AverageRating average_rating = 1; + Rating rating = 2; + bool can_rate = 3; +} diff --git a/protocol/proto/podcast_segments.proto b/protocol/proto/podcast_segments.proto new file mode 100644 index 00000000..52a075f3 --- /dev/null +++ b/protocol/proto/podcast_segments.proto @@ -0,0 +1,47 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastSegmentsProto"; +option java_package = "com.spotify.podcastsegments.proto"; + +message PodcastSegments { + string episode_uri = 1; + repeated PlaybackSegment playback_segments = 2; + repeated EmbeddedSegment embedded_segments = 3; + bool can_upsell = 4; + string album_mosaic_uri = 5; + repeated string artists = 6; + int32 duration_ms = 7; +} + +message PlaybackSegment { + string uri = 1; + int32 start_ms = 2; + int32 stop_ms = 3; + int32 duration_ms = 4; + SegmentType type = 5; + string title = 6; + string subtitle = 7; + string image_url = 8; + string action_url = 9; + bool is_abridged = 10; +} + +message EmbeddedSegment { + string uri = 1; + int32 absolute_start_ms = 2; + int32 absolute_stop_ms = 3; +} + +enum SegmentType { + UNKNOWN = 0; + TALK = 1; + MUSIC = 2; + UPSELL = 3; +} diff --git a/protocol/proto/podcast_segments_cosmos_request.proto b/protocol/proto/podcast_segments_cosmos_request.proto new file mode 100644 index 00000000..1d5a51f4 --- /dev/null +++ b/protocol/proto/podcast_segments_cosmos_request.proto @@ -0,0 +1,37 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments.cosmos.proto; + +import "policy/album_decoration_policy.proto"; +import "policy/artist_decoration_policy.proto"; +import "policy/episode_decoration_policy.proto"; +import "policy/track_decoration_policy.proto"; +import "policy/show_decoration_policy.proto"; + +option optimize_for = CODE_SIZE; + +message SegmentsRequest { + repeated string episode_uris = 1; + TrackDecorationPolicy track_decoration_policy = 2; + SegmentsPolicy segments_policy = 3; + EpisodeDecorationPolicy episode_decoration_policy = 4; +} + +message TrackDecorationPolicy { + cosmos_util.proto.TrackDecorationPolicy track_policy = 1; + cosmos_util.proto.ArtistDecorationPolicy artists_policy = 2; + cosmos_util.proto.AlbumDecorationPolicy album_policy = 3; + cosmos_util.proto.ArtistDecorationPolicy album_artist_policy = 4; +} + +message SegmentsPolicy { + bool playback = 1; + bool embedded = 2; +} + +message EpisodeDecorationPolicy { + cosmos_util.proto.EpisodeDecorationPolicy episode_policy = 1; + cosmos_util.proto.ShowDecorationPolicy show_decoration_policy = 2; +} diff --git a/protocol/proto/podcast_segments_cosmos_response.proto b/protocol/proto/podcast_segments_cosmos_response.proto new file mode 100644 index 00000000..a80f7270 --- /dev/null +++ b/protocol/proto/podcast_segments_cosmos_response.proto @@ -0,0 +1,39 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments.cosmos.proto; + +import "metadata/episode_metadata.proto"; +import "podcast_segments.proto"; +import "metadata/track_metadata.proto"; + +option optimize_for = CODE_SIZE; + +message SegmentsResponse { + bool success = 1; + repeated EpisodeSegments episode_segments = 2; +} + +message EpisodeSegments { + string episode_uri = 1; + repeated DecoratedSegment segments = 2; + bool can_upsell = 3; + string album_mosaic_uri = 4; + repeated string artists = 5; + int32 duration_ms = 6; +} + +message DecoratedSegment { + string uri = 1; + int32 start_ms = 2; + int32 stop_ms = 3; + cosmos_util.proto.TrackMetadata track_metadata = 4; + SegmentType type = 5; + string title = 6; + string subtitle = 7; + string image_url = 8; + string action_url = 9; + cosmos_util.proto.EpisodeMetadata episode_metadata = 10; + bool is_abridged = 11; +} diff --git a/protocol/proto/podcast_subscription.proto b/protocol/proto/podcast_subscription.proto new file mode 100644 index 00000000..52b7f8f3 --- /dev/null +++ b/protocol/proto/podcast_subscription.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_paywalls; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastSubscriptionProto"; +option java_package = "com.spotify.podcastsubscription.proto"; + +message PodcastSubscription { + bool is_paywalled = 1; + bool is_user_subscribed = 2; + + UserExplanation user_explanation = 3; + enum UserExplanation { + SUBSCRIPTION_DIALOG = 0; + NONE = 1; + } +} diff --git a/protocol/proto/podcast_virality.proto b/protocol/proto/podcast_virality.proto new file mode 100644 index 00000000..902dca90 --- /dev/null +++ b/protocol/proto/podcast_virality.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcastvirality.v1; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastViralityProto"; +option java_package = "com.spotify.podcastvirality.proto.v1"; + +message PodcastVirality { + bool is_viral = 1; +} diff --git a/protocol/proto/podcastextensions.proto b/protocol/proto/podcastextensions.proto new file mode 100644 index 00000000..4c85e396 --- /dev/null +++ b/protocol/proto/podcastextensions.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast.extensions; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastExtensionsProto"; +option java_package = "com.spotify.podcastextensions.proto"; + +message PodcastTopics { + repeated PodcastTopic topics = 1; +} + +message PodcastTopic { + string uri = 1; + string title = 2; +} + +message PodcastHtmlDescription { + Header header = 1; + message Header { + + } + + string html_description = 2; +} diff --git a/protocol/proto/policy/album_decoration_policy.proto b/protocol/proto/policy/album_decoration_policy.proto new file mode 100644 index 00000000..359347d4 --- /dev/null +++ b/protocol/proto/policy/album_decoration_policy.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message AlbumDecorationPolicy { + bool link = 1; + bool name = 2; + bool copyrights = 3; + bool covers = 4; + bool year = 5; + bool num_discs = 6; + bool num_tracks = 7; + bool playability = 8; + bool is_premium_only = 9; +} + +message AlbumCollectionDecorationPolicy { + bool collection_link = 1; + bool num_tracks_in_collection = 2; + bool complete = 3; +} + +message AlbumSyncDecorationPolicy { + bool inferred_offline = 1; + bool offline_state = 2; + bool sync_progress = 3; +} diff --git a/protocol/proto/policy/artist_decoration_policy.proto b/protocol/proto/policy/artist_decoration_policy.proto new file mode 100644 index 00000000..0419dc31 --- /dev/null +++ b/protocol/proto/policy/artist_decoration_policy.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message ArtistDecorationPolicy { + bool link = 1; + bool name = 2; + bool is_various_artists = 3; + bool portraits = 4; +} + +message ArtistCollectionDecorationPolicy { + bool collection_link = 1; + bool is_followed = 2; + bool num_tracks_in_collection = 3; + bool num_albums_in_collection = 4; + bool is_banned = 5; + bool can_ban = 6; +} + +message ArtistSyncDecorationPolicy { + bool inferred_offline = 1; + bool offline_state = 2; + bool sync_progress = 3; +} diff --git a/protocol/proto/policy/episode_decoration_policy.proto b/protocol/proto/policy/episode_decoration_policy.proto new file mode 100644 index 00000000..467426bd --- /dev/null +++ b/protocol/proto/policy/episode_decoration_policy.proto @@ -0,0 +1,64 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +import "extension_kind.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message EpisodeDecorationPolicy { + bool link = 1; + bool length = 2; + bool name = 3; + bool manifest_id = 4; + bool preview_id = 5; + bool preview_manifest_id = 6; + bool description = 7; + bool publish_date = 8; + bool covers = 9; + bool freeze_frames = 10; + bool language = 11; + bool available = 12; + bool media_type_enum = 13; + bool number = 14; + bool backgroundable = 15; + bool is_explicit = 16; + bool type = 17; + bool is_music_and_talk = 18; + PodcastSegmentsPolicy podcast_segments = 19; + bool podcast_subscription = 20; + repeated extendedmetadata.ExtensionKind extension = 21; + bool is_19_plus_only = 22; + bool is_book_chapter = 23; +} + +message EpisodeCollectionDecorationPolicy { + bool is_following_show = 1; + bool is_in_listen_later = 2; + bool is_new = 3; +} + +message EpisodeSyncDecorationPolicy { + bool offline = 1; + bool sync_progress = 2; +} + +message EpisodePlayedStateDecorationPolicy { + bool time_left = 1; + bool is_played = 2; + bool playable = 3; + bool playability_restriction = 4; + bool last_played_at = 5; +} + +message PodcastSegmentsPolicy { + bool playback_segments = 1; + bool embedded_segments = 2; + bool can_upsell = 3; + bool album_mosaic_uri = 4; + bool artists = 5; +} diff --git a/protocol/proto/policy/folder_decoration_policy.proto b/protocol/proto/policy/folder_decoration_policy.proto new file mode 100644 index 00000000..0d47e4d6 --- /dev/null +++ b/protocol/proto/policy/folder_decoration_policy.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message FolderDecorationPolicy { + bool row_id = 1; + bool id = 2; + bool link = 3; + bool name = 4; + bool folders = 5; + bool playlists = 6; + bool recursive_folders = 7; + bool recursive_playlists = 8; + bool rows = 9; +} diff --git a/protocol/proto/policy/playlist_album_decoration_policy.proto b/protocol/proto/policy/playlist_album_decoration_policy.proto new file mode 100644 index 00000000..01537c78 --- /dev/null +++ b/protocol/proto/policy/playlist_album_decoration_policy.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "policy/album_decoration_policy.proto"; +import "policy/artist_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistAlbumDecorationPolicy { + cosmos_util.proto.AlbumDecorationPolicy album = 1; + cosmos_util.proto.ArtistDecorationPolicy artist = 2; +} diff --git a/protocol/proto/policy/playlist_decoration_policy.proto b/protocol/proto/policy/playlist_decoration_policy.proto new file mode 100644 index 00000000..a6aef1b7 --- /dev/null +++ b/protocol/proto/policy/playlist_decoration_policy.proto @@ -0,0 +1,63 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "extension_kind.proto"; +import "policy/user_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistAllowsDecorationPolicy { + bool insert = 1; + bool remove = 2; +} + +message PlaylistDecorationPolicy { + bool row_id = 1; + bool link = 2; + bool name = 3; + bool load_state = 4; + bool loaded = 5; + bool collaborative = 6; + bool length = 7; + bool last_modification = 8; + bool total_length = 9; + bool duration = 10; + bool description = 11; + bool picture = 12; + bool playable = 13; + bool description_from_annotate = 14; + bool picture_from_annotate = 15; + bool can_report_annotation_abuse = 16; + bool followed = 17; + bool followers = 18; + bool owned_by_self = 19; + bool offline = 20; + bool sync_progress = 21; + bool published = 22; + bool browsable_offline = 23; + bool format_list_type = 24; + bool format_list_attributes = 25; + bool has_explicit_content = 26; + bool contains_spotify_tracks = 27; + bool contains_tracks = 28; + bool contains_episodes = 29; + bool contains_audio_episodes = 30; + bool only_contains_explicit = 31; + bool is_on_demand_in_free = 32; + UserDecorationPolicy owner = 33; + UserDecorationPolicy made_for = 34; + PlaylistAllowsDecorationPolicy allows = 35; + bool number_of_episodes = 36; + bool number_of_tracks = 37; + bool prefer_linear_playback = 38; + bool on_demand_in_free_reason = 39; + CollaboratingUsersDecorationPolicy collaborating_users = 40; + bool base_permission = 41; + bool user_capabilities = 42; + repeated extendedmetadata.ExtensionKind extension = 43; +} diff --git a/protocol/proto/policy/playlist_episode_decoration_policy.proto b/protocol/proto/policy/playlist_episode_decoration_policy.proto new file mode 100644 index 00000000..4e038944 --- /dev/null +++ b/protocol/proto/policy/playlist_episode_decoration_policy.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "policy/episode_decoration_policy.proto"; +import "policy/show_decoration_policy.proto"; +import "policy/user_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistEpisodeDecorationPolicy { + cosmos_util.proto.EpisodeDecorationPolicy episode = 1; + bool row_id = 2; + bool add_time = 3; + bool format_list_attributes = 4; + cosmos_util.proto.EpisodeCollectionDecorationPolicy collection = 5; + cosmos_util.proto.EpisodeSyncDecorationPolicy sync = 6; + cosmos_util.proto.EpisodePlayedStateDecorationPolicy played_state = 7; + UserDecorationPolicy added_by = 8; + cosmos_util.proto.ShowDecorationPolicy show = 9; +} diff --git a/protocol/proto/policy/playlist_request_decoration_policy.proto b/protocol/proto/policy/playlist_request_decoration_policy.proto new file mode 100644 index 00000000..a1663d28 --- /dev/null +++ b/protocol/proto/policy/playlist_request_decoration_policy.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "policy/playlist_decoration_policy.proto"; +import "policy/playlist_episode_decoration_policy.proto"; +import "policy/playlist_track_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistRequestDecorationPolicy { + PlaylistDecorationPolicy playlist = 1; + PlaylistTrackDecorationPolicy track = 2; + PlaylistEpisodeDecorationPolicy episode = 3; +} diff --git a/protocol/proto/policy/playlist_track_decoration_policy.proto b/protocol/proto/policy/playlist_track_decoration_policy.proto new file mode 100644 index 00000000..97eb0187 --- /dev/null +++ b/protocol/proto/policy/playlist_track_decoration_policy.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "policy/artist_decoration_policy.proto"; +import "policy/track_decoration_policy.proto"; +import "policy/playlist_album_decoration_policy.proto"; +import "policy/user_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistTrackDecorationPolicy { + cosmos_util.proto.TrackDecorationPolicy track = 1; + bool row_id = 2; + bool add_time = 3; + bool in_collection = 4; + bool can_add_to_collection = 5; + bool is_banned = 6; + bool can_ban = 7; + bool local_file = 8; + bool offline = 9; + bool format_list_attributes = 10; + bool display_covers = 11; + UserDecorationPolicy added_by = 12; + PlaylistAlbumDecorationPolicy album = 13; + cosmos_util.proto.ArtistDecorationPolicy artist = 14; +} diff --git a/protocol/proto/policy/rootlist_folder_decoration_policy.proto b/protocol/proto/policy/rootlist_folder_decoration_policy.proto new file mode 100644 index 00000000..f93888b4 --- /dev/null +++ b/protocol/proto/policy/rootlist_folder_decoration_policy.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "policy/folder_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message RootlistFolderDecorationPolicy { + optional bool add_time = 1; + optional FolderDecorationPolicy folder = 2; + optional bool group_label = 3; +} diff --git a/protocol/proto/policy/rootlist_playlist_decoration_policy.proto b/protocol/proto/policy/rootlist_playlist_decoration_policy.proto new file mode 100644 index 00000000..9e8446ab --- /dev/null +++ b/protocol/proto/policy/rootlist_playlist_decoration_policy.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "policy/playlist_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message RootlistPlaylistDecorationPolicy { + optional bool add_time = 1; + optional PlaylistDecorationPolicy playlist = 2; + optional bool group_label = 3; +} diff --git a/protocol/proto/policy/rootlist_request_decoration_policy.proto b/protocol/proto/policy/rootlist_request_decoration_policy.proto new file mode 100644 index 00000000..ebad00ca --- /dev/null +++ b/protocol/proto/policy/rootlist_request_decoration_policy.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "policy/rootlist_folder_decoration_policy.proto"; +import "policy/rootlist_playlist_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message RootlistRequestDecorationPolicy { + optional bool unfiltered_length = 1; + optional bool unranged_length = 2; + optional bool is_loading_contents = 3; + optional RootlistPlaylistDecorationPolicy playlist = 4; + optional RootlistFolderDecorationPolicy folder = 5; +} diff --git a/protocol/proto/policy/show_decoration_policy.proto b/protocol/proto/policy/show_decoration_policy.proto new file mode 100644 index 00000000..2e5e2020 --- /dev/null +++ b/protocol/proto/policy/show_decoration_policy.proto @@ -0,0 +1,35 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +import "extension_kind.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message ShowDecorationPolicy { + bool link = 1; + bool name = 2; + bool description = 3; + bool popularity = 4; + bool publisher = 5; + bool language = 6; + bool is_explicit = 7; + bool covers = 8; + bool num_episodes = 9; + bool consumption_order = 10; + bool media_type_enum = 11; + bool copyrights = 12; + bool trailer_uri = 13; + bool is_music_and_talk = 14; + bool access_info = 15; + repeated extendedmetadata.ExtensionKind extension = 16; + bool is_book = 17; +} + +message ShowPlayedStateDecorationPolicy { + bool latest_played_episode_link = 1; +} diff --git a/protocol/proto/policy/track_decoration_policy.proto b/protocol/proto/policy/track_decoration_policy.proto new file mode 100644 index 00000000..aa71f497 --- /dev/null +++ b/protocol/proto/policy/track_decoration_policy.proto @@ -0,0 +1,48 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message TrackDecorationPolicy { + bool has_lyrics = 1; + bool link = 2; + bool name = 3; + bool length = 4; + bool playable = 5; + bool is_available_in_metadata_catalogue = 6; + bool locally_playable = 7; + bool playable_local_track = 8; + bool disc_number = 9; + bool track_number = 10; + bool is_explicit = 11; + bool preview_id = 12; + bool is_local = 13; + bool is_premium_only = 14; + bool playable_track_link = 15; + bool popularity = 16; + bool is_19_plus_only = 17; + bool track_descriptors = 18; +} + +message TrackPlayedStateDecorationPolicy { + bool playable = 1; + bool is_currently_playable = 2; + bool playability_restriction = 3; +} + +message TrackCollectionDecorationPolicy { + bool is_in_collection = 1; + bool can_add_to_collection = 2; + bool is_banned = 3; + bool can_ban = 4; +} + +message TrackSyncDecorationPolicy { + bool offline_state = 1; + bool sync_progress = 2; +} diff --git a/protocol/proto/policy/user_decoration_policy.proto b/protocol/proto/policy/user_decoration_policy.proto new file mode 100644 index 00000000..f2c342eb --- /dev/null +++ b/protocol/proto/policy/user_decoration_policy.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message UserDecorationPolicy { + bool username = 1; + bool link = 2; + bool name = 3; + bool image = 4; + bool thumbnail = 5; + bool color = 6; +} + +message CollaboratorPolicy { + UserDecorationPolicy user = 1; + bool number_of_items = 2; + bool number_of_tracks = 3; + bool number_of_episodes = 4; + bool is_owner = 5; +} + +message CollaboratingUsersDecorationPolicy { + bool count = 1; + int32 limit = 2; + CollaboratorPolicy collaborator = 3; +} diff --git a/protocol/proto/popcount.proto b/protocol/proto/popcount.proto deleted file mode 100644 index 7a0bac84..00000000 --- a/protocol/proto/popcount.proto +++ /dev/null @@ -1,13 +0,0 @@ -syntax = "proto2"; - -message PopcountRequest { -} - -message PopcountResult { - optional sint64 count = 0x1; - optional bool truncated = 0x2; - repeated string user = 0x3; - repeated sint64 subscriptionTimestamps = 0x4; - repeated sint64 insertionTimestamps = 0x5; -} - diff --git a/protocol/proto/popcount2_external.proto b/protocol/proto/popcount2_external.proto new file mode 100644 index 00000000..069cdcfd --- /dev/null +++ b/protocol/proto/popcount2_external.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.popcount2.proto; + +option optimize_for = CODE_SIZE; + +message PopcountRequest { + +} + +message PopcountResult { + optional sint64 count = 1; + optional bool truncated = 2; + repeated string user = 3; +} + +message PopcountUserUpdate { + optional string user = 1; + optional sint64 timestamp = 2; + optional bool added = 3; +} + +message PopcountUpdate { + repeated PopcountUserUpdate updates = 1; + optional sint64 common_timestamp = 2; + optional sint64 remove_older_than_timestamp = 3; + optional bool verify_counter = 4; +} diff --git a/protocol/proto/prepare_play_options.proto b/protocol/proto/prepare_play_options.proto new file mode 100644 index 00000000..cb27650d --- /dev/null +++ b/protocol/proto/prepare_play_options.proto @@ -0,0 +1,37 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_player_options.proto"; +import "player_license.proto"; +import "skip_to_track.proto"; + +option optimize_for = CODE_SIZE; + +message PreparePlayOptions { + optional ContextPlayerOptionOverrides player_options_override = 1; + optional PlayerLicense license = 2; + map configuration_override = 3; + optional string playback_id = 4; + optional bool always_play_something = 5; + optional SkipToTrack skip_to_track = 6; + optional int64 seek_to = 7; + optional bool initially_paused = 8; + optional bool system_initiated = 9; + repeated string suppressions = 10; + optional PrefetchLevel prefetch_level = 11; + optional string session_id = 12; + optional AudioStream audio_stream = 13; +} + +enum PrefetchLevel { + kNone = 0; + kMedia = 1; +} + +enum AudioStream { + kDefault = 0; + kAlarm = 1; +} diff --git a/protocol/proto/presence.proto b/protocol/proto/presence.proto deleted file mode 100644 index 5e9be377..00000000 --- a/protocol/proto/presence.proto +++ /dev/null @@ -1,94 +0,0 @@ -syntax = "proto2"; - -message PlaylistPublishedState { - optional string uri = 0x1; - optional int64 timestamp = 0x2; -} - -message PlaylistTrackAddedState { - optional string playlist_uri = 0x1; - optional string track_uri = 0x2; - optional int64 timestamp = 0x3; -} - -message TrackFinishedPlayingState { - optional string uri = 0x1; - optional string context_uri = 0x2; - optional int64 timestamp = 0x3; - optional string referrer_uri = 0x4; -} - -message FavoriteAppAddedState { - optional string app_uri = 0x1; - optional int64 timestamp = 0x2; -} - -message TrackStartedPlayingState { - optional string uri = 0x1; - optional string context_uri = 0x2; - optional int64 timestamp = 0x3; - optional string referrer_uri = 0x4; -} - -message UriSharedState { - optional string uri = 0x1; - optional string message = 0x2; - optional int64 timestamp = 0x3; -} - -message ArtistFollowedState { - optional string uri = 0x1; - optional string artist_name = 0x2; - optional string artist_cover_uri = 0x3; - optional int64 timestamp = 0x4; -} - -message DeviceInformation { - optional string os = 0x1; - optional string type = 0x2; -} - -message GenericPresenceState { - optional int32 type = 0x1; - optional int64 timestamp = 0x2; - optional string item_uri = 0x3; - optional string item_name = 0x4; - optional string item_image = 0x5; - optional string context_uri = 0x6; - optional string context_name = 0x7; - optional string context_image = 0x8; - optional string referrer_uri = 0x9; - optional string referrer_name = 0xa; - optional string referrer_image = 0xb; - optional string message = 0xc; - optional DeviceInformation device_information = 0xd; -} - -message State { - optional int64 timestamp = 0x1; - optional Type type = 0x2; - enum Type { - PLAYLIST_PUBLISHED = 0x1; - PLAYLIST_TRACK_ADDED = 0x2; - TRACK_FINISHED_PLAYING = 0x3; - FAVORITE_APP_ADDED = 0x4; - TRACK_STARTED_PLAYING = 0x5; - URI_SHARED = 0x6; - ARTIST_FOLLOWED = 0x7; - GENERIC = 0xb; - } - optional string uri = 0x3; - optional PlaylistPublishedState playlist_published = 0x4; - optional PlaylistTrackAddedState playlist_track_added = 0x5; - optional TrackFinishedPlayingState track_finished_playing = 0x6; - optional FavoriteAppAddedState favorite_app_added = 0x7; - optional TrackStartedPlayingState track_started_playing = 0x8; - optional UriSharedState uri_shared = 0x9; - optional ArtistFollowedState artist_followed = 0xa; - optional GenericPresenceState generic = 0xb; -} - -message StateList { - repeated State states = 0x1; -} - diff --git a/protocol/proto/profile_cosmos.proto b/protocol/proto/profile_cosmos.proto new file mode 100644 index 00000000..c6c945db --- /dev/null +++ b/protocol/proto/profile_cosmos.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.profile_cosmos.proto; + +import "identity.proto"; + +option optimize_for = CODE_SIZE; + +message GetProfilesRequest { + repeated string usernames = 1; +} + +message GetProfilesResponse { + repeated identity.v3.UserProfile profiles = 1; +} + +message ChangeDisplayNameRequest { + string username = 1; + string display_name = 2; +} diff --git a/protocol/proto/profile_service.proto b/protocol/proto/profile_service.proto new file mode 100644 index 00000000..194e5fea --- /dev/null +++ b/protocol/proto/profile_service.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.profile_esperanto.proto.v1; + +import "identity.proto"; + +option optimize_for = CODE_SIZE; + +service ProfileService { + rpc GetProfiles(GetProfilesRequest) returns (GetProfilesResponse); + rpc SubscribeToProfiles(GetProfilesRequest) returns (stream GetProfilesResponse); + rpc ChangeDisplayName(ChangeDisplayNameRequest) returns (ChangeDisplayNameResponse); +} + +message GetProfilesRequest { + repeated string usernames = 1; +} + +message GetProfilesResponse { + repeated identity.v3.UserProfile profiles = 1; + int32 status_code = 2; +} + +message ChangeDisplayNameRequest { + string username = 1; + string display_name = 2; +} + +message ChangeDisplayNameResponse { + int32 status_code = 1; +} diff --git a/protocol/proto/property_definition.proto b/protocol/proto/property_definition.proto new file mode 100644 index 00000000..9df7caa7 --- /dev/null +++ b/protocol/proto/property_definition.proto @@ -0,0 +1,44 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.remote_config.ucs.proto; + +option optimize_for = CODE_SIZE; + +message PropertyDefinition { + Identifier id = 1; + message Identifier { + string scope = 1; + string name = 2; + } + + Metadata metadata = 4; + message Metadata { + string component_id = 1; + string description = 2; + } + + oneof specification { + BoolSpec bool_spec = 5; + IntSpec int_spec = 6; + EnumSpec enum_spec = 7; + } + + //reserved 2, "hash"; + + message BoolSpec { + bool default = 1; + } + + message IntSpec { + int32 default = 1; + int32 lower = 2; + int32 upper = 3; + } + + message EnumSpec { + string default = 1; + repeated string values = 2; + } +} diff --git a/protocol/proto/protobuf_delta.proto b/protocol/proto/protobuf_delta.proto new file mode 100644 index 00000000..c0a89fec --- /dev/null +++ b/protocol/proto/protobuf_delta.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.protobuf_deltas.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message Delta { + required Type type = 1; + enum Type { + DELETE = 0; + INSERT = 1; + } + + required uint32 index = 2; + required uint32 length = 3; +} diff --git a/protocol/proto/queue.proto b/protocol/proto/queue.proto new file mode 100644 index 00000000..24b45b7c --- /dev/null +++ b/protocol/proto/queue.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message Queue { + repeated ContextTrack tracks = 1; + optional bool is_playing_queue = 2; +} diff --git a/protocol/proto/radio.proto b/protocol/proto/radio.proto deleted file mode 100644 index 7a8f3bde..00000000 --- a/protocol/proto/radio.proto +++ /dev/null @@ -1,58 +0,0 @@ -syntax = "proto2"; - -message RadioRequest { - repeated string uris = 0x1; - optional int32 salt = 0x2; - optional int32 length = 0x4; - optional string stationId = 0x5; - repeated string lastTracks = 0x6; -} - -message MultiSeedRequest { - repeated string uris = 0x1; -} - -message Feedback { - optional string uri = 0x1; - optional string type = 0x2; - optional double timestamp = 0x3; -} - -message Tracks { - repeated string gids = 0x1; - optional string source = 0x2; - optional string identity = 0x3; - repeated string tokens = 0x4; - repeated Feedback feedback = 0x5; -} - -message Station { - optional string id = 0x1; - optional string title = 0x2; - optional string titleUri = 0x3; - optional string subtitle = 0x4; - optional string subtitleUri = 0x5; - optional string imageUri = 0x6; - optional double lastListen = 0x7; - repeated string seeds = 0x8; - optional int32 thumbsUp = 0x9; - optional int32 thumbsDown = 0xa; -} - -message Rules { - optional string js = 0x1; -} - -message StationResponse { - optional Station station = 0x1; - repeated Feedback feedback = 0x2; -} - -message StationList { - repeated Station stations = 0x1; -} - -message LikedPlaylist { - optional string uri = 0x1; -} - diff --git a/protocol/proto/rate_limited_events.proto b/protocol/proto/rate_limited_events.proto new file mode 100644 index 00000000..c9116b6d --- /dev/null +++ b/protocol/proto/rate_limited_events.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RateLimitedEventsEntity { + int32 file_format_version = 1; + map map_field = 2; +} diff --git a/protocol/proto/rcs.proto b/protocol/proto/rcs.proto new file mode 100644 index 00000000..00e86103 --- /dev/null +++ b/protocol/proto/rcs.proto @@ -0,0 +1,107 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.remote_config.proto; + +option optimize_for = CODE_SIZE; + +message GranularConfiguration { + repeated AssignedPropertyValue properties = 1; + message AssignedPropertyValue { + Platform platform = 7; + string client_id = 4; + string component_id = 5; + int64 groupId = 8; + string name = 6; + + oneof structured_value { + BoolValue bool_value = 1; + IntValue int_value = 2; + EnumValue enum_value = 3; + } + + message BoolValue { + bool value = 1; + } + + message IntValue { + int32 value = 1; + } + + message EnumValue { + string value = 1; + } + } + + int64 rcs_fetch_time = 2; + string configuration_assignment_id = 3; +} + +message PolicyGroupId { + int64 policy_id = 1; + int64 policy_group_id = 2; +} + +message ClientPropertySet { + string client_id = 1; + string version = 2; + repeated PropertyDefinition properties = 5; + + repeated ComponentInfo component_infos = 6; + message ComponentInfo { + string name = 3; + + //reserved 1, 2, "owner", "tags"; + } + + string property_set_key = 7; + + PublisherInfo publisherInfo = 8; + message PublisherInfo { + string published_for_client_version = 1; + int64 published_at = 2; + } +} + +message PropertyDefinition { + string description = 2; + string component_id = 3; + Platform platform = 8; + + oneof identifier { + string id = 9; + string name = 7; + } + + oneof spec { + BoolSpec bool_spec = 4; + IntSpec int_spec = 5; + EnumSpec enum_spec = 6; + } + + reserved 1; + + message BoolSpec { + bool default = 1; + } + + message IntSpec { + int32 default = 1; + int32 lower = 2; + int32 upper = 3; + } + + message EnumSpec { + string default = 1; + repeated string values = 2; + } +} + +enum Platform { + UNKNOWN_PLATFORM = 0; + ANDROID_PLATFORM = 1; + BACKEND_PLATFORM = 2; + IOS_PLATFORM = 3; + WEB_PLATFORM = 4; +} diff --git a/protocol/proto/recently_played.proto b/protocol/proto/recently_played.proto new file mode 100644 index 00000000..fd22fdd9 --- /dev/null +++ b/protocol/proto/recently_played.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.recently_played.proto; + +option optimize_for = CODE_SIZE; + +message Item { + string link = 1; + int64 timestamp = 2; + bool hidden = 3; +} diff --git a/protocol/proto/recently_played_backend.proto b/protocol/proto/recently_played_backend.proto new file mode 100644 index 00000000..fa137288 --- /dev/null +++ b/protocol/proto/recently_played_backend.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.recently_played_backend.proto; + +option optimize_for = CODE_SIZE; + +message Context { + optional string uri = 1; + optional int64 lastPlayedTime = 2; +} + +message RecentlyPlayed { + repeated Context contexts = 1; + optional int32 offset = 2; + optional int32 total = 3; +} diff --git a/protocol/proto/record_id.proto b/protocol/proto/record_id.proto new file mode 100644 index 00000000..167c0ecd --- /dev/null +++ b/protocol/proto/record_id.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RecordId { + uint64 value = 1; +} diff --git a/protocol/proto/remote.proto b/protocol/proto/remote.proto new file mode 100644 index 00000000..a81c1c0f --- /dev/null +++ b/protocol/proto/remote.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.shuffle.remote; + +option optimize_for = CODE_SIZE; + +message ServiceRequest { + repeated Track tracks = 1; + message Track { + required string uri = 1; + required string uid = 2; + } +} + +message ServiceResponse { + repeated uint32 order = 1; +} diff --git a/protocol/proto/repeating_track_node.proto b/protocol/proto/repeating_track_node.proto new file mode 100644 index 00000000..d4691cd2 --- /dev/null +++ b/protocol/proto/repeating_track_node.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "track_instance.proto"; +import "track_instantiator.proto"; + +option optimize_for = CODE_SIZE; + +message RepeatingTrackNode { + optional TrackInstance instance = 1; + optional TrackInstantiator instantiator = 2; +} diff --git a/protocol/proto/request_failure.proto b/protocol/proto/request_failure.proto new file mode 100644 index 00000000..10deb1be --- /dev/null +++ b/protocol/proto/request_failure.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.image.proto; + +option optimize_for = CODE_SIZE; + +message RequestFailure { + optional string request = 1; + optional string source = 2; + optional string error = 3; + optional int64 result = 4; +} diff --git a/protocol/proto/resolve.proto b/protocol/proto/resolve.proto new file mode 100644 index 00000000..793b8c5a --- /dev/null +++ b/protocol/proto/resolve.proto @@ -0,0 +1,116 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.remote_config.ucs.proto; + +import "property_definition.proto"; + +option optimize_for = CODE_SIZE; + +message ResolveRequest { + string property_set_id = 1; + Fetch fetch_type = 2; + Context context = 11; + + oneof resolution_context { + BackendContext backend_context = 12 [deprecated = true]; + } + + //reserved 4, 5, "custom_context", "projection"; +} + +message ResolveResponse { + Configuration configuration = 1; +} + +message Configuration { + string configuration_assignment_id = 1; + int64 fetch_time_millis = 2; + + repeated AssignedValue assigned_values = 3; + message AssignedValue { + PropertyDefinition.Identifier property_id = 1; + + Metadata metadata = 2; + message Metadata { + int64 policy_id = 1; + string external_realm = 2; + int64 external_realm_id = 3; + } + + oneof structured_value { + BoolValue bool_value = 3; + IntValue int_value = 4; + EnumValue enum_value = 5; + } + + message BoolValue { + bool value = 1; + } + + message IntValue { + int32 value = 1; + } + + message EnumValue { + string value = 1; + } + } +} + +message Fetch { + Type type = 1; + enum Type { + BLOCKING = 0; + BACKGROUND_SYNC = 1; + ASYNC = 2; + PUSH_INITIATED = 3; + RECONNECT = 4; + } +} + +message Context { + repeated ContextEntry context = 1; + message ContextEntry { + string value = 10; + + oneof context { + DynamicContext.KnownContext known_context = 1; + } + } +} + +message BackendContext { + string system = 1 [deprecated = true]; + string service_name = 2 [deprecated = true]; + + StaticContext static_context = 3; + message StaticContext { + string system = 1; + string service_name = 2; + } + + DynamicContext dynamic_context = 4; + + SurfaceMetadata surface_metadata = 10; + message SurfaceMetadata { + string backend_sdk_version = 1; + } +} + +message DynamicContext { + repeated ContextDefinition context_definition = 1; + message ContextDefinition { + oneof context { + KnownContext known_context = 1; + } + } + + enum KnownContext { + KNOWN_CONTEXT_INVALID = 0; + KNOWN_CONTEXT_USER_ID = 1; + KNOWN_CONTEXT_INSTALLATION_ID = 2; + KNOWN_CONTEXT_VERSION = 3; + } +} diff --git a/protocol/proto/resource_type.proto b/protocol/proto/resource_type.proto new file mode 100644 index 00000000..ccea6920 --- /dev/null +++ b/protocol/proto/resource_type.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.offline.proto; + +option optimize_for = CODE_SIZE; + +enum ResourceType { + OTHER = 0; + AUDIO = 1; + DRM = 2; + IMAGE = 3; + VIDEO = 4; +} diff --git a/protocol/proto/response_status.proto b/protocol/proto/response_status.proto new file mode 100644 index 00000000..5709571f --- /dev/null +++ b/protocol/proto/response_status.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +option objc_class_prefix = "SPTPlaylistEsperanto"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message ResponseStatus { + int32 status_code = 1; + string reason = 2; +} diff --git a/protocol/proto/restrictions.proto b/protocol/proto/restrictions.proto new file mode 100644 index 00000000..0661858c --- /dev/null +++ b/protocol/proto/restrictions.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message Restrictions { + repeated string disallow_pausing_reasons = 1; + repeated string disallow_resuming_reasons = 2; + repeated string disallow_seeking_reasons = 3; + repeated string disallow_peeking_prev_reasons = 4; + repeated string disallow_peeking_next_reasons = 5; + repeated string disallow_skipping_prev_reasons = 6; + repeated string disallow_skipping_next_reasons = 7; + repeated string disallow_toggling_repeat_context_reasons = 8; + repeated string disallow_toggling_repeat_track_reasons = 9; + repeated string disallow_toggling_shuffle_reasons = 10; + repeated string disallow_set_queue_reasons = 11; + repeated string disallow_interrupting_playback_reasons = 12; + repeated string disallow_transferring_playback_reasons = 13; + repeated string disallow_remote_control_reasons = 14; + repeated string disallow_inserting_into_next_tracks_reasons = 15; + repeated string disallow_inserting_into_context_tracks_reasons = 16; + repeated string disallow_reordering_in_next_tracks_reasons = 17; + repeated string disallow_reordering_in_context_tracks_reasons = 18; + repeated string disallow_removing_from_next_tracks_reasons = 19; + repeated string disallow_removing_from_context_tracks_reasons = 20; + repeated string disallow_updating_context_reasons = 21; +} diff --git a/protocol/proto/resume_points_node.proto b/protocol/proto/resume_points_node.proto new file mode 100644 index 00000000..9f7eed8e --- /dev/null +++ b/protocol/proto/resume_points_node.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify_shows.proto; + +option optimize_for = CODE_SIZE; + +message ResumePointsNode { + optional int64 resume_point = 1; +} diff --git a/protocol/proto/rootlist_request.proto b/protocol/proto/rootlist_request.proto new file mode 100644 index 00000000..ae055475 --- /dev/null +++ b/protocol/proto/rootlist_request.proto @@ -0,0 +1,46 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist.cosmos.rootlist_request.proto; + +import "playlist_folder_state.proto"; +import "playlist_permission.proto"; +import "playlist_playlist_state.proto"; +import "protobuf_delta.proto"; + +option objc_class_prefix = "SPTPlaylistCosmosRootlist"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message Playlist { + optional string row_id = 1; + optional cosmos.proto.PlaylistMetadata playlist_metadata = 2; + optional cosmos.proto.PlaylistOfflineState playlist_offline_state = 3; + optional uint32 add_time = 4; + optional bool is_on_demand_in_free = 5; + optional string group_label = 6; + optional playlist_permission.proto.Capabilities capabilities = 7; +} + +message Item { + optional string header_field = 1; + optional Folder folder = 2; + optional Playlist playlist = 3; + optional protobuf_deltas.proto.Delta delta = 4; +} + +message Folder { + repeated Item item = 1; + optional cosmos.proto.FolderMetadata folder_metadata = 2; + optional string row_id = 3; + optional uint32 add_time = 4; + optional string group_label = 5; +} + +message Response { + optional Folder root = 1; + optional int32 unfiltered_length = 2; + optional int32 unranged_length = 3; + optional bool is_loading_contents = 4; +} diff --git a/protocol/proto/search.proto b/protocol/proto/search.proto deleted file mode 100644 index 38b717f7..00000000 --- a/protocol/proto/search.proto +++ /dev/null @@ -1,44 +0,0 @@ -syntax = "proto2"; - -message SearchRequest { - optional string query = 0x1; - optional Type type = 0x2; - enum Type { - TRACK = 0x0; - ALBUM = 0x1; - ARTIST = 0x2; - PLAYLIST = 0x3; - USER = 0x4; - } - optional int32 limit = 0x3; - optional int32 offset = 0x4; - optional bool did_you_mean = 0x5; - optional string spotify_uri = 0x2; - repeated bytes file_id = 0x3; - optional string url = 0x4; - optional string slask_id = 0x5; -} - -message Playlist { - optional string uri = 0x1; - optional string name = 0x2; - repeated Image image = 0x3; -} - -message User { - optional string username = 0x1; - optional string full_name = 0x2; - repeated Image image = 0x3; - optional sint32 followers = 0x4; -} - -message SearchReply { - optional sint32 hits = 0x1; - repeated Track track = 0x2; - repeated Album album = 0x3; - repeated Artist artist = 0x4; - repeated Playlist playlist = 0x5; - optional string did_you_mean = 0x6; - repeated User user = 0x7; -} - diff --git a/protocol/proto/seek_to_position.proto b/protocol/proto/seek_to_position.proto new file mode 100644 index 00000000..6f426842 --- /dev/null +++ b/protocol/proto/seek_to_position.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message SeekToPosition { + optional uint64 value = 1; + optional uint32 revision = 2; +} diff --git a/protocol/proto/sequence_number_entity.proto b/protocol/proto/sequence_number_entity.proto new file mode 100644 index 00000000..a3b88c81 --- /dev/null +++ b/protocol/proto/sequence_number_entity.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message SequenceNumberEntity { + uint32 file_format_version = 1; + string event_name = 2; + bytes sequence_id = 3; + uint64 sequence_number_next = 4; +} diff --git a/protocol/proto/session.proto b/protocol/proto/session.proto new file mode 100644 index 00000000..7c4589f3 --- /dev/null +++ b/protocol/proto/session.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +import "context.proto"; +import "context_player_options.proto"; +import "play_origin.proto"; +import "suppressions.proto"; +import "instrumentation_params.proto"; + +option optimize_for = CODE_SIZE; + +message Session { + optional PlayOrigin play_origin = 1; + optional Context context = 2; + optional string current_uid = 3; + optional ContextPlayerOptionOverrides option_overrides = 4; + optional Suppressions suppressions = 5; + optional InstrumentationParams instrumentation_params = 6; +} diff --git a/protocol/proto/set_member_permission_request.proto b/protocol/proto/set_member_permission_request.proto new file mode 100644 index 00000000..160eaf92 --- /dev/null +++ b/protocol/proto/set_member_permission_request.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "playlist_permission.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message SetMemberPermissionRequest { + optional string playlist_uri = 1; + optional string username = 2; + optional playlist_permission.proto.PermissionLevel permission_level = 3; + optional uint32 timeout_ms = 4; +} diff --git a/protocol/proto/show_access.proto b/protocol/proto/show_access.proto new file mode 100644 index 00000000..eddc0342 --- /dev/null +++ b/protocol/proto/show_access.proto @@ -0,0 +1,50 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.podcast_paywalls; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "ShowAccessProto"; +option java_package = "com.spotify.podcast.access.proto"; + +message ShowAccess { + AccountLinkPrompt prompt = 5; + + oneof explanation { + NoExplanation none = 1; + LegacyExplanation legacy = 2; + BasicExplanation basic = 3; + UpsellLinkExplanation upsellLink = 4; + } +} + +message BasicExplanation { + string title = 1; + string body = 2; + string cta = 3; +} + +message LegacyExplanation { + +} + +message NoExplanation { + +} + +message UpsellLinkExplanation { + string title = 1; + string body = 2; + string cta = 3; + string url = 4; +} + +message AccountLinkPrompt { + string title = 1; + string body = 2; + string cta = 3; + string url = 4; +} diff --git a/protocol/proto/show_episode_state.proto b/protocol/proto/show_episode_state.proto new file mode 100644 index 00000000..b780dbb6 --- /dev/null +++ b/protocol/proto/show_episode_state.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.show_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message EpisodeCollectionState { + optional bool is_following_show = 1; + optional bool is_new = 2; + optional bool is_in_listen_later = 3; +} + +message EpisodeOfflineState { + optional string offline_state = 1; + optional uint32 sync_progress = 2; +} diff --git a/protocol/proto/show_request.proto b/protocol/proto/show_request.proto new file mode 100644 index 00000000..3624fa04 --- /dev/null +++ b/protocol/proto/show_request.proto @@ -0,0 +1,79 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.show_cosmos.proto; + +import "metadata/episode_metadata.proto"; +import "metadata/show_metadata.proto"; +import "played_state/episode_played_state.proto"; +import "show_episode_state.proto"; +import "show_show_state.proto"; +import "podcast_virality.proto"; +import "transcripts.proto"; +import "podcastextensions.proto"; +import "clips_cover.proto"; +import "show_access.proto"; +import "podcast_ratings.proto"; +import "greenroom_extension.proto"; + +option objc_class_prefix = "SPTShowCosmos"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; + optional EpisodeCollectionState episode_collection_state = 3; + optional EpisodeOfflineState episode_offline_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 5; + optional corex.transcripts.metadata.EpisodeTranscript episode_transcripts = 7; + optional podcastvirality.v1.PodcastVirality episode_virality = 8; + optional clips.ClipsCover episode_clips = 9; + + reserved 6; +} + +message Header { + optional cosmos_util.proto.ShowMetadata show_metadata = 1; + optional ShowCollectionState show_collection_state = 2; + optional ShowPlayState show_play_state = 3; +} + +message Response { + repeated Item item = 1; + optional Header header = 2; + optional uint32 unfiltered_length = 4; + optional uint32 length = 5; + optional bool loading_contents = 6; + optional uint32 unranged_length = 7; + optional AuxiliarySections auxiliary_sections = 8; + optional podcast_paywalls.ShowAccess access_info = 9; + optional uint32 range_offset = 10; + + reserved 3, "online_data"; +} + +message AuxiliarySections { + optional ContinueListeningSection continue_listening = 1; + optional podcast.extensions.PodcastTopics topics_section = 2; + optional TrailerSection trailer_section = 3; + optional podcast.extensions.PodcastHtmlDescription html_description_section = 5; + optional clips.ClipsCover clips_section = 6; + optional ratings.PodcastRating rating_section = 7; + optional greenroom.api.extendedmetadata.v1.GreenroomSection greenroom_section = 8; + optional LatestUnplayedEpisodeSection latest_unplayed_episode_section = 9; + + reserved 4; +} + +message ContinueListeningSection { + optional Item item = 1; +} + +message TrailerSection { + optional Item item = 1; +} + +message LatestUnplayedEpisodeSection { + optional Item item = 1; +} diff --git a/protocol/proto/show_show_state.proto b/protocol/proto/show_show_state.proto new file mode 100644 index 00000000..c9c3548a --- /dev/null +++ b/protocol/proto/show_show_state.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.show_cosmos.proto; + +option objc_class_prefix = "SPTShowCosmos"; +option optimize_for = CODE_SIZE; + +message ShowCollectionState { + optional bool is_in_collection = 1; +} + +message ShowPlayState { + optional string latest_played_episode_link = 1; +} diff --git a/protocol/proto/skip_to_track.proto b/protocol/proto/skip_to_track.proto new file mode 100644 index 00000000..67b5f717 --- /dev/null +++ b/protocol/proto/skip_to_track.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message SkipToTrack { + optional string page_url = 1; + optional uint64 page_index = 2; + optional string track_uid = 3; + optional string track_uri = 4; + optional uint64 track_index = 5; +} diff --git a/protocol/proto/social.proto b/protocol/proto/social.proto deleted file mode 100644 index 58d39a18..00000000 --- a/protocol/proto/social.proto +++ /dev/null @@ -1,12 +0,0 @@ -syntax = "proto2"; - -message DecorationData { - optional string username = 0x1; - optional string full_name = 0x2; - optional string image_url = 0x3; - optional string large_image_url = 0x5; - optional string first_name = 0x6; - optional string last_name = 0x7; - optional string facebook_uid = 0x8; -} - diff --git a/protocol/proto/social_connect_v2.proto b/protocol/proto/social_connect_v2.proto new file mode 100644 index 00000000..f4d084c8 --- /dev/null +++ b/protocol/proto/social_connect_v2.proto @@ -0,0 +1,77 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package socialconnect; + +option optimize_for = CODE_SIZE; + +message Session { + int64 timestamp = 1; + string session_id = 2; + string join_session_token = 3; + string join_session_url = 4; + string session_owner_id = 5; + repeated SessionMember session_members = 6; + string join_session_uri = 7; + bool is_session_owner = 9; + bool is_listening = 10; + bool is_controlling = 11; + bool is_discoverable = 12; + SessionType initial_session_type = 13; + + oneof _host_active_device_id { + string host_active_device_id = 14; + } + + reserved 8; +} + +message SessionMember { + int64 timestamp = 1; + string id = 2; + string username = 3; + string display_name = 4; + string image_url = 5; + string large_image_url = 6; + bool is_listening = 7; + bool is_controlling = 8; +} + +message SessionUpdate { + Session session = 1; + SessionUpdateReason reason = 2; + repeated SessionMember updated_session_members = 3; +} + +message DevicesExposure { + int64 timestamp = 1; + map devices_exposure = 2; +} + +enum SessionType { + UNKNOWN_SESSION_TYPE = 0; + IN_PERSON = 3; + REMOTE = 4; + REMOTE_V2 = 5; +} + +enum SessionUpdateReason { + UNKNOWN_UPDATE_TYPE = 0; + NEW_SESSION = 1; + USER_JOINED = 2; + USER_LEFT = 3; + SESSION_DELETED = 4; + YOU_LEFT = 5; + YOU_WERE_KICKED = 6; + YOU_JOINED = 7; + PARTICIPANT_PROMOTED_TO_HOST = 8; + DISCOVERABILITY_CHANGED = 9; + USER_KICKED = 10; +} + +enum DeviceExposureStatus { + NOT_EXPOSABLE = 0; + NOT_EXPOSED = 1; + EXPOSED = 2; +} diff --git a/protocol/proto/social_service.proto b/protocol/proto/social_service.proto new file mode 100644 index 00000000..d5c108a8 --- /dev/null +++ b/protocol/proto/social_service.proto @@ -0,0 +1,52 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.social_esperanto.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.social.esperanto.proto"; + +service SocialService { + rpc SetAccessToken(SetAccessTokenRequest) returns (SetAccessTokenResponse); + rpc SubscribeToEvents(SubscribeToEventsRequest) returns (stream SubscribeToEventsResponse); + rpc SubscribeToState(SubscribeToStateRequest) returns (stream SubscribeToStateResponse); +} + +message SetAccessTokenRequest { + string accessToken = 1; +} + +message SetAccessTokenResponse { + +} + +message SubscribeToEventsRequest { + +} + +message SubscribeToEventsResponse { + Error status = 1; + enum Error { + NONE = 0; + FAILED_TO_CONNECT = 1; + USER_DATA_FAIL = 2; + PERMISSIONS = 3; + SERVICE_CONNECT_NOT_PERMITTED = 4; + USER_UNAUTHORIZED = 5; + } + + string description = 2; +} + +message SubscribeToStateRequest { + +} + +message SubscribeToStateResponse { + bool available = 1; + bool enabled = 2; + repeated string missingPermissions = 3; + string accessToken = 4; +} diff --git a/protocol/proto/socialgraph.proto b/protocol/proto/socialgraph.proto deleted file mode 100644 index 3adc1306..00000000 --- a/protocol/proto/socialgraph.proto +++ /dev/null @@ -1,49 +0,0 @@ -syntax = "proto2"; - -message CountReply { - repeated int32 counts = 0x1; -} - -message UserListRequest { - optional string last_result = 0x1; - optional int32 count = 0x2; - optional bool include_length = 0x3; -} - -message UserListReply { - repeated User users = 0x1; - optional int32 length = 0x2; -} - -message User { - optional string username = 0x1; - optional int32 subscriber_count = 0x2; - optional int32 subscription_count = 0x3; -} - -message ArtistListReply { - repeated Artist artists = 0x1; -} - -message Artist { - optional string artistid = 0x1; - optional int32 subscriber_count = 0x2; -} - -message StringListRequest { - repeated string args = 0x1; -} - -message StringListReply { - repeated string reply = 0x1; -} - -message TopPlaylistsRequest { - optional string username = 0x1; - optional int32 count = 0x2; -} - -message TopPlaylistsReply { - repeated string uris = 0x1; -} - diff --git a/protocol/proto/socialgraph_response_status.proto b/protocol/proto/socialgraph_response_status.proto new file mode 100644 index 00000000..1518daf1 --- /dev/null +++ b/protocol/proto/socialgraph_response_status.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.socialgraph_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.socialgraph.esperanto.proto"; + +message ResponseStatus { + int32 status_code = 1; + string reason = 2; +} diff --git a/protocol/proto/socialgraphv2.proto b/protocol/proto/socialgraphv2.proto new file mode 100644 index 00000000..ace70589 --- /dev/null +++ b/protocol/proto/socialgraphv2.proto @@ -0,0 +1,45 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.socialgraph.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.socialgraph.proto"; + +message SocialGraphEntity { + optional string user_uri = 1; + optional string artist_uri = 2; + optional int32 followers_count = 3; + optional int32 following_count = 4; + optional int32 status = 5; + optional bool is_following = 6; + optional bool is_followed = 7; + optional bool is_dismissed = 8; + optional bool is_blocked = 9; + optional int64 following_at = 10; + optional int64 followed_at = 11; + optional int64 dismissed_at = 12; + optional int64 blocked_at = 13; +} + +message SocialGraphRequest { + repeated string target_uris = 1; + optional string source_uri = 2; +} + +message SocialGraphReply { + repeated SocialGraphEntity entities = 1; + optional int32 num_total_entities = 2; +} + +message ChangeNotification { + optional EventType event_type = 1; + repeated SocialGraphEntity entities = 2; +} + +enum EventType { + FOLLOW = 1; + UNFOLLOW = 2; +} diff --git a/protocol/proto/spotify/clienttoken/v0/clienttoken_http.proto b/protocol/proto/spotify/clienttoken/v0/clienttoken_http.proto new file mode 100644 index 00000000..c60cdcaf --- /dev/null +++ b/protocol/proto/spotify/clienttoken/v0/clienttoken_http.proto @@ -0,0 +1,126 @@ +syntax = "proto3"; + +package spotify.clienttoken.http.v0; + +import "connectivity.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.clienttoken.http.v0"; + +message ClientTokenRequest { + ClientTokenRequestType request_type = 1; + + oneof request { + ClientDataRequest client_data = 2; + ChallengeAnswersRequest challenge_answers = 3; + } +} + +message ClientDataRequest { + string client_version = 1; + string client_id = 2; + + oneof data { + spotify.clienttoken.data.v0.ConnectivitySdkData connectivity_sdk_data = 3; + } +} + +message ChallengeAnswersRequest { + string state = 1; + repeated ChallengeAnswer answers = 2; +} + +message ClientTokenResponse { + ClientTokenResponseType response_type = 1; + + oneof response { + GrantedTokenResponse granted_token = 2; + ChallengesResponse challenges = 3; + } +} + +message TokenDomain { + string domain = 1; +} + +message GrantedTokenResponse { + string token = 1; + int32 expires_after_seconds = 2; + int32 refresh_after_seconds = 3; + repeated TokenDomain domains = 4; +} + +message ChallengesResponse { + string state = 1; + repeated Challenge challenges = 2; +} + +message ClientSecretParameters { + string salt = 1; +} + +message EvaluateJSParameters { + string code = 1; + repeated string libraries = 2; +} + +message HashCashParameters { + int32 length = 1; + string prefix = 2; +} + +message Challenge { + ChallengeType type = 1; + + oneof parameters { + ClientSecretParameters client_secret_parameters = 2; + EvaluateJSParameters evaluate_js_parameters = 3; + HashCashParameters evaluate_hashcash_parameters = 4; + } +} + +message ClientSecretHMACAnswer { + string hmac = 1; +} + +message EvaluateJSAnswer { + string result = 1; +} + +message HashCashAnswer { + string suffix = 1; +} + +message ChallengeAnswer { + ChallengeType ChallengeType = 1; + + oneof answer { + ClientSecretHMACAnswer client_secret = 2; + EvaluateJSAnswer evaluate_js = 3; + HashCashAnswer hash_cash = 4; + } +} + +message ClientTokenBadRequest { + string message = 1; +} + +enum ClientTokenRequestType { + REQUEST_UNKNOWN = 0; + REQUEST_CLIENT_DATA_REQUEST = 1; + REQUEST_CHALLENGE_ANSWERS_REQUEST = 2; +} + +enum ClientTokenResponseType { + RESPONSE_UNKNOWN = 0; + RESPONSE_GRANTED_TOKEN_RESPONSE = 1; + RESPONSE_CHALLENGES_RESPONSE = 2; +} + +enum ChallengeType { + CHALLENGE_UNKNOWN = 0; + CHALLENGE_CLIENT_SECRET_HMAC = 1; + CHALLENGE_EVALUATE_JS = 2; + CHALLENGE_HASH_CASH = 3; +} diff --git a/protocol/proto/spotify/login5/v3/challenges/code.proto b/protocol/proto/spotify/login5/v3/challenges/code.proto new file mode 100644 index 00000000..980d3de3 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/challenges/code.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3.challenges; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.challenges.proto"; + +message CodeChallenge { + Method method = 1; + enum Method { + UNKNOWN = 0; + SMS = 1; + } + + int32 code_length = 2; + int32 expires_in = 3; + string canonical_phone_number = 4; +} + +message CodeSolution { + string code = 1; +} diff --git a/protocol/proto/spotify/login5/v3/challenges/hashcash.proto b/protocol/proto/spotify/login5/v3/challenges/hashcash.proto new file mode 100644 index 00000000..3e83981c --- /dev/null +++ b/protocol/proto/spotify/login5/v3/challenges/hashcash.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3.challenges; + +import "google/protobuf/duration.proto"; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.challenges.proto"; + +message HashcashChallenge { + bytes prefix = 1; + int32 length = 2; +} + +message HashcashSolution { + bytes suffix = 1; + google.protobuf.Duration duration = 2; +} diff --git a/protocol/proto/spotify/login5/v3/client_info.proto b/protocol/proto/spotify/login5/v3/client_info.proto new file mode 100644 index 00000000..575891e1 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/client_info.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.proto"; + +message ClientInfo { + string client_id = 1; + string device_id = 2; +} diff --git a/protocol/proto/spotify/login5/v3/credentials/credentials.proto b/protocol/proto/spotify/login5/v3/credentials/credentials.proto new file mode 100644 index 00000000..c1f43953 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/credentials/credentials.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3.credentials; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.credentials.proto"; + +message StoredCredential { + string username = 1; + bytes data = 2; +} + +message Password { + string id = 1; + string password = 2; + bytes padding = 3; +} + +message FacebookAccessToken { + string fb_uid = 1; + string access_token = 2; +} + +message OneTimeToken { + string token = 1; +} + +message ParentChildCredential { + string child_id = 1; + StoredCredential parent_stored_credential = 2; +} + +message AppleSignInCredential { + string auth_code = 1; + string redirect_uri = 2; + string bundle_id = 3; +} + +message SamsungSignInCredential { + string auth_code = 1; + string redirect_uri = 2; + string id_token = 3; + string token_endpoint_url = 4; +} + +message GoogleSignInCredential { + string auth_code = 1; + string redirect_uri = 2; +} diff --git a/protocol/proto/spotify/login5/v3/identifiers/identifiers.proto b/protocol/proto/spotify/login5/v3/identifiers/identifiers.proto new file mode 100644 index 00000000..b82e9942 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/identifiers/identifiers.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3.identifiers; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.identifiers.proto"; + +message PhoneNumber { + string number = 1; + string iso_country_code = 2; + string country_calling_code = 3; +} diff --git a/protocol/proto/spotify/login5/v3/login5.proto b/protocol/proto/spotify/login5/v3/login5.proto new file mode 100644 index 00000000..4b41dcb2 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/login5.proto @@ -0,0 +1,94 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3; + +import "spotify/login5/v3/client_info.proto"; +import "spotify/login5/v3/user_info.proto"; +import "spotify/login5/v3/challenges/code.proto"; +import "spotify/login5/v3/challenges/hashcash.proto"; +import "spotify/login5/v3/credentials/credentials.proto"; +import "spotify/login5/v3/identifiers/identifiers.proto"; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.proto"; + +message Challenges { + repeated Challenge challenges = 1; +} + +message Challenge { + oneof challenge { + challenges.HashcashChallenge hashcash = 1; + challenges.CodeChallenge code = 2; + } +} + +message ChallengeSolutions { + repeated ChallengeSolution solutions = 1; +} + +message ChallengeSolution { + oneof solution { + challenges.HashcashSolution hashcash = 1; + challenges.CodeSolution code = 2; + } +} + +message LoginRequest { + ClientInfo client_info = 1; + bytes login_context = 2; + ChallengeSolutions challenge_solutions = 3; + + oneof login_method { + credentials.StoredCredential stored_credential = 100; + credentials.Password password = 101; + credentials.FacebookAccessToken facebook_access_token = 102; + identifiers.PhoneNumber phone_number = 103; + credentials.OneTimeToken one_time_token = 104; + credentials.ParentChildCredential parent_child_credential = 105; + credentials.AppleSignInCredential apple_sign_in_credential = 106; + credentials.SamsungSignInCredential samsung_sign_in_credential = 107; + credentials.GoogleSignInCredential google_sign_in_credential = 108; + } +} + +message LoginOk { + string username = 1; + string access_token = 2; + bytes stored_credential = 3; + int32 access_token_expires_in = 4; +} + +message LoginResponse { + repeated Warnings warnings = 4; + enum Warnings { + UNKNOWN_WARNING = 0; + DEPRECATED_PROTOCOL_VERSION = 1; + } + + bytes login_context = 5; + string identifier_token = 6; + UserInfo user_info = 7; + + oneof response { + LoginOk ok = 1; + LoginError error = 2; + Challenges challenges = 3; + } +} + +enum LoginError { + UNKNOWN_ERROR = 0; + INVALID_CREDENTIALS = 1; + BAD_REQUEST = 2; + UNSUPPORTED_LOGIN_PROTOCOL = 3; + TIMEOUT = 4; + UNKNOWN_IDENTIFIER = 5; + TOO_MANY_ATTEMPTS = 6; + INVALID_PHONENUMBER = 7; + TRY_AGAIN_LATER = 8; +} diff --git a/protocol/proto/spotify/login5/v3/user_info.proto b/protocol/proto/spotify/login5/v3/user_info.proto new file mode 100644 index 00000000..a7e040cc --- /dev/null +++ b/protocol/proto/spotify/login5/v3/user_info.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.proto"; + +message UserInfo { + string name = 1; + string email = 2; + bool email_verified = 3; + string birthdate = 4; + + Gender gender = 5; + enum Gender { + UNKNOWN = 0; + MALE = 1; + FEMALE = 2; + NEUTRAL = 3; + } + + string phone_number = 6; + bool phone_number_verified = 7; + bool email_already_registered = 8; +} diff --git a/protocol/proto/state_restore/ads_rules_inject_tracks.proto b/protocol/proto/state_restore/ads_rules_inject_tracks.proto new file mode 100644 index 00000000..569c8cdf --- /dev/null +++ b/protocol/proto/state_restore/ads_rules_inject_tracks.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/provided_track.proto"; + +option optimize_for = CODE_SIZE; + +message AdsRulesInjectTracks { + repeated ProvidedTrack ads = 1; + optional bool is_playing_slot = 2; +} diff --git a/protocol/proto/state_restore/behavior_metadata_rules.proto b/protocol/proto/state_restore/behavior_metadata_rules.proto new file mode 100644 index 00000000..4bb65cd4 --- /dev/null +++ b/protocol/proto/state_restore/behavior_metadata_rules.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message BehaviorMetadataRules { + repeated string page_instance_ids = 1; + repeated string interaction_ids = 2; +} diff --git a/protocol/proto/state_restore/circuit_breaker_rules.proto b/protocol/proto/state_restore/circuit_breaker_rules.proto new file mode 100644 index 00000000..e81eaf57 --- /dev/null +++ b/protocol/proto/state_restore/circuit_breaker_rules.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message CircuitBreakerRules { + repeated string discarded_track_uids = 1; + required int32 num_errored_tracks = 2; + required bool context_track_played = 3; +} diff --git a/protocol/proto/state_restore/context_player_rules.proto b/protocol/proto/state_restore/context_player_rules.proto new file mode 100644 index 00000000..b06bf8e8 --- /dev/null +++ b/protocol/proto/state_restore/context_player_rules.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/context_player_rules_base.proto"; +import "state_restore/mft_rules.proto"; + +option optimize_for = CODE_SIZE; + +message ContextPlayerRules { + optional ContextPlayerRulesBase base = 1; + optional MftRules mft_rules = 2; + map sub_rules = 3; +} diff --git a/protocol/proto/state_restore/context_player_rules_base.proto b/protocol/proto/state_restore/context_player_rules_base.proto new file mode 100644 index 00000000..da973bba --- /dev/null +++ b/protocol/proto/state_restore/context_player_rules_base.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/ads_rules_inject_tracks.proto"; +import "state_restore/behavior_metadata_rules.proto"; +import "state_restore/circuit_breaker_rules.proto"; +import "state_restore/explicit_content_rules.proto"; +import "state_restore/explicit_request_rules.proto"; +import "state_restore/mft_rules_core.proto"; +import "state_restore/mod_rules_interruptions.proto"; +import "state_restore/music_injection_rules.proto"; +import "state_restore/remove_banned_tracks_rules.proto"; +import "state_restore/resume_points_rules.proto"; +import "state_restore/track_error_rules.proto"; + +option optimize_for = CODE_SIZE; + +message ContextPlayerRulesBase { + optional BehaviorMetadataRules behavior_metadata_rules = 1; + optional CircuitBreakerRules circuit_breaker_rules = 2; + optional ExplicitContentRules explicit_content_rules = 3; + optional ExplicitRequestRules explicit_request_rules = 4; + optional MusicInjectionRules music_injection_rules = 5; + optional RemoveBannedTracksRules remove_banned_tracks_rules = 6; + optional ResumePointsRules resume_points_rules = 7; + optional TrackErrorRules track_error_rules = 8; + optional AdsRulesInjectTracks ads_rules_inject_tracks = 9; + optional MftRulesCore mft_rules_core = 10; + optional ModRulesInterruptions mod_rules_interruptions = 11; +} diff --git a/protocol/proto/state_restore/explicit_content_rules.proto b/protocol/proto/state_restore/explicit_content_rules.proto new file mode 100644 index 00000000..271ad6ea --- /dev/null +++ b/protocol/proto/state_restore/explicit_content_rules.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message ExplicitContentRules { + required bool filter_explicit_content = 1; + required bool filter_age_restricted_content = 2; +} diff --git a/protocol/proto/state_restore/explicit_request_rules.proto b/protocol/proto/state_restore/explicit_request_rules.proto new file mode 100644 index 00000000..babda5cb --- /dev/null +++ b/protocol/proto/state_restore/explicit_request_rules.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message ExplicitRequestRules { + required bool always_play_something = 1; +} diff --git a/protocol/proto/state_restore/mft_context_history.proto b/protocol/proto/state_restore/mft_context_history.proto new file mode 100644 index 00000000..48e77205 --- /dev/null +++ b/protocol/proto/state_restore/mft_context_history.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message MftContextHistoryEntry { + required ContextTrack track = 1; + required int64 timestamp = 2; + optional int64 position = 3; +} + +message MftContextHistory { + map lookup = 1; +} diff --git a/protocol/proto/state_restore/mft_context_switch_rules.proto b/protocol/proto/state_restore/mft_context_switch_rules.proto new file mode 100644 index 00000000..d01e9298 --- /dev/null +++ b/protocol/proto/state_restore/mft_context_switch_rules.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message MftContextSwitchRules { + required bool has_played_track = 1; + required bool enabled = 2; +} diff --git a/protocol/proto/state_restore/mft_fallback_page_history.proto b/protocol/proto/state_restore/mft_fallback_page_history.proto new file mode 100644 index 00000000..54d15e8d --- /dev/null +++ b/protocol/proto/state_restore/mft_fallback_page_history.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message ContextAndPage { + required string context_uri = 1; + required string fallback_page_url = 2; +} + +message MftFallbackPageHistory { + repeated ContextAndPage context_to_fallback_page = 1; +} diff --git a/protocol/proto/state_restore/mft_rules.proto b/protocol/proto/state_restore/mft_rules.proto new file mode 100644 index 00000000..141cdac7 --- /dev/null +++ b/protocol/proto/state_restore/mft_rules.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/context_player_rules_base.proto"; + +option optimize_for = CODE_SIZE; + +message PlayEvents { + required int32 max_consecutive = 1; + required int32 max_occurrences_in_period = 2; + required int64 period = 3; +} + +message SkipEvents { + required int32 max_occurrences_in_period = 1; + required int64 period = 2; +} + +message Context { + required int32 min_tracks = 1; +} + +message MftConfiguration { + optional PlayEvents track = 1; + optional PlayEvents album = 2; + optional PlayEvents artist = 3; + optional SkipEvents skip = 4; + optional Context context = 5; +} + +message MftRules { + required bool locked = 1; + optional MftConfiguration config = 2; + map forward_rules = 3; +} diff --git a/protocol/proto/state_restore/mft_rules_core.proto b/protocol/proto/state_restore/mft_rules_core.proto new file mode 100644 index 00000000..05549624 --- /dev/null +++ b/protocol/proto/state_restore/mft_rules_core.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/mft_context_switch_rules.proto"; +import "state_restore/mft_rules_inject_filler_tracks.proto"; + +option optimize_for = CODE_SIZE; + +message MftRulesCore { + required MftRulesInjectFillerTracks inject_filler_tracks = 1; + required MftContextSwitchRules context_switch_rules = 2; + repeated string feature_classes = 3; +} diff --git a/protocol/proto/state_restore/mft_rules_inject_filler_tracks.proto b/protocol/proto/state_restore/mft_rules_inject_filler_tracks.proto new file mode 100644 index 00000000..b5b8c657 --- /dev/null +++ b/protocol/proto/state_restore/mft_rules_inject_filler_tracks.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "context_track.proto"; +import "state_restore/random_source.proto"; + +option optimize_for = CODE_SIZE; + +message MftRandomTrackInjection { + required RandomSource random_source = 1; + required int32 offset = 2; +} + +message MftRulesInjectFillerTracks { + repeated ContextTrack fallback_tracks = 1; + required MftRandomTrackInjection padding_track_injection = 2; + required RandomSource random_source = 3; + required bool filter_explicit_content = 4; + repeated string feature_classes = 5; +} diff --git a/protocol/proto/state_restore/mft_state.proto b/protocol/proto/state_restore/mft_state.proto new file mode 100644 index 00000000..8f5f9561 --- /dev/null +++ b/protocol/proto/state_restore/mft_state.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message EventList { + repeated int64 event_times = 1; +} + +message LastEvent { + required string uri = 1; + required int32 when = 2; +} + +message History { + map when = 1; + required LastEvent last = 2; +} + +message MftState { + required History track = 1; + required History social_track = 2; + required History album = 3; + required History artist = 4; + required EventList skip = 5; + required int32 time = 6; + required bool did_skip = 7; +} diff --git a/protocol/proto/state_restore/mod_interruption_state.proto b/protocol/proto/state_restore/mod_interruption_state.proto new file mode 100644 index 00000000..e09ffe13 --- /dev/null +++ b/protocol/proto/state_restore/mod_interruption_state.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "context_track.proto"; +import "state_restore/provided_track.proto"; + +option optimize_for = CODE_SIZE; + +message StoredInterruption { + required ContextTrack context_track = 1; + required int64 fetched_at = 2; +} + +message ModInterruptionState { + optional string context_uri = 1; + optional ProvidedTrack last_track = 2; + map active_play_count = 3; + repeated StoredInterruption active_play_interruptions = 4; + repeated StoredInterruption repeat_play_interruptions = 5; +} diff --git a/protocol/proto/state_restore/mod_rules_interruptions.proto b/protocol/proto/state_restore/mod_rules_interruptions.proto new file mode 100644 index 00000000..1b965ccd --- /dev/null +++ b/protocol/proto/state_restore/mod_rules_interruptions.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "player_license.proto"; +import "state_restore/provided_track.proto"; + +option optimize_for = CODE_SIZE; + +message ModRulesInterruptions { + optional ProvidedTrack seek_repeat_track = 1; + required uint32 prng_seed = 2; + required bool support_video = 3; + required bool is_active_action = 4; + required bool is_in_seek_repeat = 5; + required bool has_tp_api_restrictions = 6; + required InterruptionSource interruption_source = 7; + required PlayerLicense license = 8; +} + +enum InterruptionSource { + Context_IS = 1; + SAS = 2; + NoInterruptions = 3; +} diff --git a/protocol/proto/state_restore/music_injection_rules.proto b/protocol/proto/state_restore/music_injection_rules.proto new file mode 100644 index 00000000..5ae18bce --- /dev/null +++ b/protocol/proto/state_restore/music_injection_rules.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message InjectionSegment { + required string track_uri = 1; + optional int64 start = 2; + optional int64 stop = 3; + required int64 duration = 4; +} + +message InjectionModel { + optional string episode_uri = 1; + optional int64 total_duration = 2; + repeated InjectionSegment segments = 3; +} + +message MusicInjectionRules { + optional InjectionModel injection_model = 1; + optional bytes playback_id = 2; +} diff --git a/protocol/proto/state_restore/player_session_queue.proto b/protocol/proto/state_restore/player_session_queue.proto new file mode 100644 index 00000000..22ee7941 --- /dev/null +++ b/protocol/proto/state_restore/player_session_queue.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message SessionJson { + optional string json = 1; +} + +message QueuedSession { + optional Trigger trigger = 1; + optional SessionJson session = 2; +} + +message PlayerSessionQueue { + optional SessionJson active = 1; + repeated SessionJson pushed = 2; + repeated QueuedSession queued = 3; +} + +enum Trigger { + DID_GO_PAST_TRACK = 1; + DID_GO_PAST_CONTEXT = 2; +} diff --git a/protocol/proto/state_restore/provided_track.proto b/protocol/proto/state_restore/provided_track.proto new file mode 100644 index 00000000..a61010e5 --- /dev/null +++ b/protocol/proto/state_restore/provided_track.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "restrictions.proto"; + +option optimize_for = CODE_SIZE; + +message ProvidedTrack { + optional string uid = 1; + optional string uri = 2; + map metadata = 3; + optional string provider = 4; + repeated string removed = 5; + repeated string blocked = 6; + map internal_metadata = 7; + optional Restrictions restrictions = 8; +} diff --git a/protocol/proto/state_restore/random_source.proto b/protocol/proto/state_restore/random_source.proto new file mode 100644 index 00000000..f1ad1019 --- /dev/null +++ b/protocol/proto/state_restore/random_source.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message RandomSource { + required uint64 random_0 = 1; + required uint64 random_1 = 2; +} diff --git a/protocol/proto/state_restore/remove_banned_tracks_rules.proto b/protocol/proto/state_restore/remove_banned_tracks_rules.proto new file mode 100644 index 00000000..9db5c70c --- /dev/null +++ b/protocol/proto/state_restore/remove_banned_tracks_rules.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message Strings { + repeated string strings = 1; +} + +message RemoveBannedTracksRules { + repeated string banned_tracks = 1; + repeated string banned_albums = 2; + repeated string banned_artists = 3; + map banned_context_tracks = 4; +} diff --git a/protocol/proto/state_restore/resume_points_rules.proto b/protocol/proto/state_restore/resume_points_rules.proto new file mode 100644 index 00000000..6f2618a9 --- /dev/null +++ b/protocol/proto/state_restore/resume_points_rules.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message ResumePoint { + required bool is_fully_played = 1; + required int64 position = 2; + required int64 timestamp = 3; +} + +message ResumePointsRules { + map resume_points = 1; +} diff --git a/protocol/proto/state_restore/track_error_rules.proto b/protocol/proto/state_restore/track_error_rules.proto new file mode 100644 index 00000000..e13b8562 --- /dev/null +++ b/protocol/proto/state_restore/track_error_rules.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message TrackErrorRules { + repeated string reasons = 1; + required int32 num_attempted_tracks = 2; + required int32 num_failed_tracks = 3; +} diff --git a/protocol/proto/status.proto b/protocol/proto/status.proto new file mode 100644 index 00000000..1293af57 --- /dev/null +++ b/protocol/proto/status.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message Status { + int32 code = 1; + string reason = 2; +} diff --git a/protocol/proto/status_code.proto b/protocol/proto/status_code.proto new file mode 100644 index 00000000..abc8bd49 --- /dev/null +++ b/protocol/proto/status_code.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum StatusCode { + SUCCESS = 0; + NO_PLAYBACK_ID = 1; + EVENT_SENDER_ERROR = 2; +} diff --git a/protocol/proto/status_response.proto b/protocol/proto/status_response.proto new file mode 100644 index 00000000..78d15c9a --- /dev/null +++ b/protocol/proto/status_response.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "status_code.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StatusResponse { + StatusCode status_code = 1; + string reason = 2; +} diff --git a/protocol/proto/storage-resolve.proto b/protocol/proto/storage-resolve.proto new file mode 100644 index 00000000..1cb3b673 --- /dev/null +++ b/protocol/proto/storage-resolve.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.download.proto; + +option optimize_for = CODE_SIZE; + +message StorageResolveResponse { + Result result = 1; + enum Result { + CDN = 0; + STORAGE = 1; + RESTRICTED = 3; + } + + repeated string cdnurl = 2; + bytes fileid = 4; +} diff --git a/protocol/proto/storage_cosmos.proto b/protocol/proto/storage_cosmos.proto new file mode 100644 index 00000000..97169850 --- /dev/null +++ b/protocol/proto/storage_cosmos.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.storage_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message GetFileCacheRangesResponse { + bool byte_size_known = 1; + uint64 byte_size = 2; + + repeated Range ranges = 3; + message Range { + uint64 from_byte = 1; + uint64 to_byte = 2; + } +} diff --git a/protocol/proto/storylines.proto b/protocol/proto/storylines.proto new file mode 100644 index 00000000..c9361966 --- /dev/null +++ b/protocol/proto/storylines.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.storylines.v1; + +option java_multiple_files = true; +option java_outer_classname = "StorylinesProto"; +option java_package = "com.spotify.storylines.v1.extended_metadata"; + +message Artist { + string uri = 1; + string name = 2; + string avatar_cdn_url = 3; +} + +message Card { + string id = 1; + string image_cdn_url = 2; + int32 image_width = 3; + int32 image_height = 4; +} + +message Storyline { + string id = 1; + string entity_uri = 2; + Artist artist = 3; + repeated Card cards = 4; +} diff --git a/protocol/proto/stream_end_request.proto b/protocol/proto/stream_end_request.proto new file mode 100644 index 00000000..ed72fd51 --- /dev/null +++ b/protocol/proto/stream_end_request.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "stream_handle.proto"; +import "play_reason.proto"; +import "media_format.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamEndRequest { + StreamHandle stream_handle = 1; + string source_end = 2; + PlayReason reason_end = 3; + MediaFormat format = 4; +} diff --git a/protocol/proto/stream_handle.proto b/protocol/proto/stream_handle.proto new file mode 100644 index 00000000..b66ed4ce --- /dev/null +++ b/protocol/proto/stream_handle.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamHandle { + string playback_id = 1; +} diff --git a/protocol/proto/stream_progress_request.proto b/protocol/proto/stream_progress_request.proto new file mode 100644 index 00000000..63fe9d80 --- /dev/null +++ b/protocol/proto/stream_progress_request.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "stream_handle.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamProgressRequest { + StreamHandle stream_handle = 1; + uint64 current_position = 2; + bool is_paused = 3; + bool is_playing_video = 4; + bool is_overlapping = 5; + bool is_background = 6; + bool is_fullscreen = 7; + bool is_external = 8; + double playback_speed = 9; +} diff --git a/protocol/proto/stream_seek_request.proto b/protocol/proto/stream_seek_request.proto new file mode 100644 index 00000000..7d99169e --- /dev/null +++ b/protocol/proto/stream_seek_request.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "stream_handle.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamSeekRequest { + StreamHandle stream_handle = 1; + uint64 from_position = 3; + uint64 to_position = 4; +} diff --git a/protocol/proto/stream_start_request.proto b/protocol/proto/stream_start_request.proto new file mode 100644 index 00000000..656016a6 --- /dev/null +++ b/protocol/proto/stream_start_request.proto @@ -0,0 +1,44 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "media_type.proto"; +import "play_reason.proto"; +import "streaming_rule.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamStartRequest { + string playback_id = 1; + string parent_playback_id = 2; + string parent_play_track = 3; + string video_session_id = 4; + string play_context = 5; + string uri = 6; + string displayed_uri = 7; + string feature_identifier = 8; + string feature_version = 9; + string view_uri = 10; + string provider = 11; + string referrer = 12; + string referrer_version = 13; + string referrer_vendor = 14; + StreamingRule streaming_rule = 15; + string connect_controller_device_id = 16; + string page_instance_id = 17; + string interaction_id = 18; + string source_start = 19; + PlayReason reason_start = 20; + bool is_shuffle = 23; + bool is_incognito = 25; + string media_id = 28; + MediaType media_type = 29; + uint64 playback_start_time = 30; + uint64 start_position = 31; + bool is_live = 32; + bool stream_was_offlined = 33; + bool client_offline = 34; +} diff --git a/protocol/proto/stream_start_response.proto b/protocol/proto/stream_start_response.proto new file mode 100644 index 00000000..98af2976 --- /dev/null +++ b/protocol/proto/stream_start_response.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "status_response.proto"; +import "stream_handle.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamStartResponse { + StatusResponse status = 1; + StreamHandle stream_handle = 2; +} diff --git a/protocol/proto/streaming_rule.proto b/protocol/proto/streaming_rule.proto new file mode 100644 index 00000000..9593fdef --- /dev/null +++ b/protocol/proto/streaming_rule.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum StreamingRule { + STREAMING_RULE_NONE = 0; + STREAMING_RULE_DMCA_RADIO = 1; + STREAMING_RULE_PREVIEW = 2; + STREAMING_RULE_WIFI = 3; + STREAMING_RULE_SHUFFLE_MODE = 4; +} diff --git a/protocol/proto/suggest.proto b/protocol/proto/suggest.proto deleted file mode 100644 index ef45f1e2..00000000 --- a/protocol/proto/suggest.proto +++ /dev/null @@ -1,43 +0,0 @@ -syntax = "proto2"; - -message Track { - optional bytes gid = 0x1; - optional string name = 0x2; - optional bytes image = 0x3; - repeated string artist_name = 0x4; - repeated bytes artist_gid = 0x5; - optional uint32 rank = 0x6; -} - -message Artist { - optional bytes gid = 0x1; - optional string name = 0x2; - optional bytes image = 0x3; - optional uint32 rank = 0x6; -} - -message Album { - optional bytes gid = 0x1; - optional string name = 0x2; - optional bytes image = 0x3; - repeated string artist_name = 0x4; - repeated bytes artist_gid = 0x5; - optional uint32 rank = 0x6; -} - -message Playlist { - optional string uri = 0x1; - optional string name = 0x2; - optional string image_uri = 0x3; - optional string owner_name = 0x4; - optional string owner_uri = 0x5; - optional uint32 rank = 0x6; -} - -message Suggestions { - repeated Track track = 0x1; - repeated Album album = 0x2; - repeated Artist artist = 0x3; - repeated Playlist playlist = 0x4; -} - diff --git a/protocol/proto/suppressions.proto b/protocol/proto/suppressions.proto new file mode 100644 index 00000000..4ddfaefb --- /dev/null +++ b/protocol/proto/suppressions.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message Suppressions { + repeated string providers = 1; +} diff --git a/protocol/proto/sync/album_sync_state.proto b/protocol/proto/sync/album_sync_state.proto new file mode 100644 index 00000000..7ea90276 --- /dev/null +++ b/protocol/proto/sync/album_sync_state.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message AlbumSyncState { + optional string offline = 1; + optional string inferred_offline = 2; + optional uint32 sync_progress = 3; +} diff --git a/protocol/proto/sync/artist_sync_state.proto b/protocol/proto/sync/artist_sync_state.proto new file mode 100644 index 00000000..03ba32f3 --- /dev/null +++ b/protocol/proto/sync/artist_sync_state.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ArtistSyncState { + optional string offline = 1; + optional string inferred_offline = 2; + optional uint32 sync_progress = 3; +} diff --git a/protocol/proto/sync/episode_sync_state.proto b/protocol/proto/sync/episode_sync_state.proto new file mode 100644 index 00000000..7dce8424 --- /dev/null +++ b/protocol/proto/sync/episode_sync_state.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message EpisodeSyncState { + optional string offline_state = 1; + optional uint32 sync_progress = 2; +} diff --git a/protocol/proto/sync/track_sync_state.proto b/protocol/proto/sync/track_sync_state.proto new file mode 100644 index 00000000..8873fad5 --- /dev/null +++ b/protocol/proto/sync/track_sync_state.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message TrackSyncState { + optional string offline = 1; + optional uint32 sync_progress = 2; +} diff --git a/protocol/proto/sync_request.proto b/protocol/proto/sync_request.proto new file mode 100644 index 00000000..b2d77625 --- /dev/null +++ b/protocol/proto/sync_request.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option objc_class_prefix = "SPTPlaylist"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message SyncRequest { + repeated string playlist_uris = 1; +} diff --git a/protocol/proto/techu_core_exercise_cosmos.proto b/protocol/proto/techu_core_exercise_cosmos.proto new file mode 100644 index 00000000..155a303f --- /dev/null +++ b/protocol/proto/techu_core_exercise_cosmos.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.techu_core_exercise_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message TechUCoreExerciseRequest { + string a = 1; + string b = 2; +} + +message TechUCoreExerciseResponse { + string concatenated = 1; +} diff --git a/protocol/proto/toplist.proto b/protocol/proto/toplist.proto deleted file mode 100644 index 1a12159f..00000000 --- a/protocol/proto/toplist.proto +++ /dev/null @@ -1,6 +0,0 @@ -syntax = "proto2"; - -message Toplist { - repeated string items = 0x1; -} - diff --git a/protocol/proto/track_instance.proto b/protocol/proto/track_instance.proto new file mode 100644 index 00000000..952f28c8 --- /dev/null +++ b/protocol/proto/track_instance.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_index.proto"; +import "context_track.proto"; +import "seek_to_position.proto"; + +option optimize_for = CODE_SIZE; + +message TrackInstance { + optional ContextTrack track = 1; + optional uint64 id = 2; + optional SeekToPosition seek_to_position = 7; + optional bool initially_paused = 4; + optional ContextIndex index = 5; + optional string provider = 6; + + reserved 3; +} diff --git a/protocol/proto/track_instantiator.proto b/protocol/proto/track_instantiator.proto new file mode 100644 index 00000000..3b8b8baf --- /dev/null +++ b/protocol/proto/track_instantiator.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message TrackInstantiator { + optional uint64 unique = 1; + optional uint64 count = 2; + optional string provider = 3; +} diff --git a/protocol/proto/transcripts.proto b/protocol/proto/transcripts.proto new file mode 100644 index 00000000..05ac7fbb --- /dev/null +++ b/protocol/proto/transcripts.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.corex.transcripts.metadata; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "TranscriptMetadataProto"; +option java_package = "com.spotify.corex.transcripts.metadata.proto"; + +message EpisodeTranscript { + string episode_uri = 1; + repeated Transcript transcripts = 2; +} + +message Transcript { + string uri = 1; + string language = 2; + bool curated = 3; + string cdn_url = 4; +} diff --git a/protocol/proto/transfer_node.proto b/protocol/proto/transfer_node.proto new file mode 100644 index 00000000..e5bbc03e --- /dev/null +++ b/protocol/proto/transfer_node.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "track_instance.proto"; +import "track_instantiator.proto"; + +option optimize_for = CODE_SIZE; + +message TransferNode { + optional TrackInstance instance = 1; + optional TrackInstantiator instantiator = 2; +} diff --git a/protocol/proto/transfer_state.proto b/protocol/proto/transfer_state.proto new file mode 100644 index 00000000..200547c0 --- /dev/null +++ b/protocol/proto/transfer_state.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +import "context_player_options.proto"; +import "playback.proto"; +import "session.proto"; +import "queue.proto"; + +option optimize_for = CODE_SIZE; + +message TransferState { + optional ContextPlayerOptions options = 1; + optional Playback playback = 2; + optional Session current_session = 3; + optional Queue queue = 4; +} diff --git a/protocol/proto/tts-resolve.proto b/protocol/proto/tts-resolve.proto new file mode 100644 index 00000000..adb50854 --- /dev/null +++ b/protocol/proto/tts-resolve.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.narration_injection.proto; + +option optimize_for = CODE_SIZE; + +service TtsResolveService { + rpc Resolve(ResolveRequest) returns (ResolveResponse); +} + +message ResolveRequest { + AudioFormat audio_format = 3; + enum AudioFormat { + UNSPECIFIED = 0; + WAV = 1; + PCM = 2; + OPUS = 3; + VORBIS = 4; + MP3 = 5; + } + + string language = 4; + + TtsVoice tts_voice = 5; + enum TtsVoice { + UNSET_TTS_VOICE = 0; + VOICE1 = 1; + VOICE2 = 2; + VOICE3 = 3; + } + + TtsProvider tts_provider = 6; + enum TtsProvider { + UNSET_TTS_PROVIDER = 0; + CLOUD_TTS = 1; + READSPEAKER = 2; + POLLY = 3; + WELL_SAID = 4; + } + + int32 sample_rate_hz = 7; + + oneof prompt { + string text = 1; + string ssml = 2; + } +} + +message ResolveResponse { + string url = 1; +} diff --git a/protocol/proto/ucs.proto b/protocol/proto/ucs.proto new file mode 100644 index 00000000..c5048f8c --- /dev/null +++ b/protocol/proto/ucs.proto @@ -0,0 +1,56 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.remote_config.ucs.proto; + +import "resolve.proto"; +import "useraccount.proto"; + +option optimize_for = CODE_SIZE; + +message UcsRequest { + CallerInfo caller_info = 1; + message CallerInfo { + string request_origin_id = 1; + string request_orgin_version = 2; + string reason = 3; + } + + ResolveRequest resolve_request = 2; + + AccountAttributesRequest account_attributes_request = 3; + message AccountAttributesRequest { + + } +} + +message UcsResponseWrapper { + oneof result { + UcsResponse success = 1; + Error error = 2; + } + + message UcsResponse { + int64 fetch_time_millis = 5; + + oneof resolve_result { + ResolveResponse resolve_success = 1; + Error resolve_error = 2; + } + + oneof account_attributes_result { + AccountAttributesResponse account_attributes_success = 3; + Error account_attributes_error = 4; + } + } + + message AccountAttributesResponse { + map account_attributes = 1; + } + + message Error { + int32 error_code = 1; + string error_message = 2; + } +} diff --git a/protocol/proto/unfinished_episodes_request.proto b/protocol/proto/unfinished_episodes_request.proto new file mode 100644 index 00000000..68e5f903 --- /dev/null +++ b/protocol/proto/unfinished_episodes_request.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.show_cosmos.unfinished_episodes_request.proto; + +import "metadata/episode_metadata.proto"; +import "played_state/episode_played_state.proto"; +import "show_episode_state.proto"; + +option objc_class_prefix = "SPTShowCosmosUnfinshedEpisodes"; +option optimize_for = CODE_SIZE; + +message Episode { + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1; + optional show_cosmos.proto.EpisodeCollectionState episode_collection_state = 2; + optional show_cosmos.proto.EpisodeOfflineState episode_offline_state = 3; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 4; + optional string link = 5; +} + +message Response { + repeated Episode episode = 2; + + reserved 1; +} diff --git a/protocol/proto/user_attributes.proto b/protocol/proto/user_attributes.proto new file mode 100644 index 00000000..96ecf010 --- /dev/null +++ b/protocol/proto/user_attributes.proto @@ -0,0 +1,29 @@ +// Custom protobuf crafted from spotify:user:attributes:mutated response: +// +// 1 { +// 1: "filter-explicit-content" +// } +// 2 { +// 1: 1639087299 +// 2: 418909000 +// } + +syntax = "proto3"; + +package spotify.user_attributes.proto; + +option optimize_for = CODE_SIZE; + +message UserAttributesMutation { + repeated MutatedField fields = 1; + MutationCommand cmd = 2; +} + +message MutatedField { + string name = 1; +} + +message MutationCommand { + int64 timestamp = 1; + int32 unknown = 2; +} diff --git a/protocol/proto/useraccount.proto b/protocol/proto/useraccount.proto new file mode 100644 index 00000000..ca8fea90 --- /dev/null +++ b/protocol/proto/useraccount.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.remote_config.ucs.proto; + +option optimize_for = CODE_SIZE; + +message AccountAttribute { + oneof value { + bool bool_value = 2; + int64 long_value = 3; + string string_value = 4; + } +} diff --git a/protocol/proto/your_library_contains_request.proto b/protocol/proto/your_library_contains_request.proto new file mode 100644 index 00000000..bbb43c20 --- /dev/null +++ b/protocol/proto/your_library_contains_request.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_pseudo_playlist_config.proto"; + +option optimize_for = CODE_SIZE; + +message YourLibraryContainsRequest { + repeated string requested_uri = 3; + YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 4; +} diff --git a/protocol/proto/your_library_contains_response.proto b/protocol/proto/your_library_contains_response.proto new file mode 100644 index 00000000..641d71a5 --- /dev/null +++ b/protocol/proto/your_library_contains_response.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option optimize_for = CODE_SIZE; + +message YourLibraryContainsResponseHeader { + bool is_loading = 2; +} + +message YourLibraryContainsResponseEntity { + string uri = 1; + bool is_in_library = 2; +} + +message YourLibraryContainsResponse { + YourLibraryContainsResponseHeader header = 1; + repeated YourLibraryContainsResponseEntity entity = 2; + string error = 99; +} diff --git a/protocol/proto/your_library_decorate_request.proto b/protocol/proto/your_library_decorate_request.proto new file mode 100644 index 00000000..6b77a976 --- /dev/null +++ b/protocol/proto/your_library_decorate_request.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_pseudo_playlist_config.proto"; + +option optimize_for = CODE_SIZE; + +message YourLibraryDecorateRequest { + repeated string requested_uri = 3; + YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 6; +} diff --git a/protocol/proto/your_library_decorate_response.proto b/protocol/proto/your_library_decorate_response.proto new file mode 100644 index 00000000..125d5c33 --- /dev/null +++ b/protocol/proto/your_library_decorate_response.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_decorated_entity.proto"; + +option optimize_for = CODE_SIZE; + +message YourLibraryDecorateResponseHeader { + bool is_loading = 2; +} + +message YourLibraryDecorateResponse { + YourLibraryDecorateResponseHeader header = 1; + repeated YourLibraryDecoratedEntity entity = 2; + string error = 99; +} diff --git a/protocol/proto/your_library_decorated_entity.proto b/protocol/proto/your_library_decorated_entity.proto new file mode 100644 index 00000000..c31b45eb --- /dev/null +++ b/protocol/proto/your_library_decorated_entity.proto @@ -0,0 +1,105 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option optimize_for = CODE_SIZE; + +message YourLibraryEntityInfo { + string key = 1; + string name = 2; + string uri = 3; + string group_label = 5; + string image_uri = 6; + bool pinned = 7; + + Pinnable pinnable = 8; + enum Pinnable { + YES = 0; + NO_IN_FOLDER = 1; + } + + Offline.Availability offline_availability = 9; +} + +message Offline { + enum Availability { + UNKNOWN = 0; + NO = 1; + YES = 2; + DOWNLOADING = 3; + WAITING = 4; + } +} + +message YourLibraryAlbumExtraInfo { + string artist_name = 1; +} + +message YourLibraryArtistExtraInfo { + +} + +message YourLibraryPlaylistExtraInfo { + string creator_name = 1; + bool is_loading = 5; + bool can_view = 6; +} + +message YourLibraryShowExtraInfo { + string creator_name = 1; + int64 publish_date = 4; + bool is_music_and_talk = 5; + int32 number_of_downloaded_episodes = 6; +} + +message YourLibraryFolderExtraInfo { + int32 number_of_playlists = 2; + int32 number_of_folders = 3; +} + +message YourLibraryLikedSongsExtraInfo { + int32 number_of_songs = 3; +} + +message YourLibraryYourEpisodesExtraInfo { + int32 number_of_downloaded_episodes = 4; +} + +message YourLibraryNewEpisodesExtraInfo { + int64 publish_date = 1; +} + +message YourLibraryLocalFilesExtraInfo { + int32 number_of_files = 1; +} + +message YourLibraryBookExtraInfo { + string author_name = 1; +} + +message YourLibraryDecoratedEntity { + YourLibraryEntityInfo entity_info = 1; + + oneof entity { + YourLibraryAlbumExtraInfo album = 2; + YourLibraryArtistExtraInfo artist = 3; + YourLibraryPlaylistExtraInfo playlist = 4; + YourLibraryShowExtraInfo show = 5; + YourLibraryFolderExtraInfo folder = 6; + YourLibraryLikedSongsExtraInfo liked_songs = 8; + YourLibraryYourEpisodesExtraInfo your_episodes = 9; + YourLibraryNewEpisodesExtraInfo new_episodes = 10; + YourLibraryLocalFilesExtraInfo local_files = 11; + YourLibraryBookExtraInfo book = 12; + } +} + +message YourLibraryAvailableEntityTypes { + bool albums = 1; + bool artists = 2; + bool playlists = 3; + bool shows = 4; + bool books = 5; +} diff --git a/protocol/proto/your_library_entity.proto b/protocol/proto/your_library_entity.proto new file mode 100644 index 00000000..897fc6c1 --- /dev/null +++ b/protocol/proto/your_library_entity.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_index.proto"; +import "collection_index.proto"; + +option optimize_for = CODE_SIZE; + +message YourLibraryShowWrapper { + collection.proto.CollectionAlbumLikeEntry show = 1; + string uri = 2; +} + +message YourLibraryBookWrapper { + collection.proto.CollectionAlbumLikeEntry book = 1; + string uri = 2; +} + +message YourLibraryEntity { + bool pinned = 1; + + oneof entity { + collection.proto.CollectionAlbumLikeEntry album = 2; + collection.proto.CollectionArtistEntry artist = 3; + YourLibraryRootlistEntity rootlist_entity = 4; + YourLibraryShowWrapper show = 7; + YourLibraryBookWrapper book = 8; + } +} diff --git a/protocol/proto/your_library_index.proto b/protocol/proto/your_library_index.proto new file mode 100644 index 00000000..835c0fa2 --- /dev/null +++ b/protocol/proto/your_library_index.proto @@ -0,0 +1,43 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option optimize_for = CODE_SIZE; + +message YourLibraryRootlistPlaylist { + string image_uri = 1; + bool is_loading = 3; + int32 rootlist_index = 4; + bool can_view = 5; +} + +message YourLibraryRootlistFolder { + int32 number_of_playlists = 1; + int32 number_of_folders = 2; + int32 rootlist_index = 3; +} + +message YourLibraryRootlistCollection { + Kind kind = 1; + enum Kind { + LIKED_SONGS = 0; + YOUR_EPISODES = 1; + NEW_EPISODES = 2; + LOCAL_FILES = 3; + } +} + +message YourLibraryRootlistEntity { + string uri = 1; + string name = 2; + string creator_name = 3; + int64 add_time = 4; + + oneof entity { + YourLibraryRootlistPlaylist playlist = 5; + YourLibraryRootlistFolder folder = 6; + YourLibraryRootlistCollection collection = 7; + } +} diff --git a/protocol/proto/your_library_pseudo_playlist_config.proto b/protocol/proto/your_library_pseudo_playlist_config.proto new file mode 100644 index 00000000..77c9bb53 --- /dev/null +++ b/protocol/proto/your_library_pseudo_playlist_config.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option optimize_for = CODE_SIZE; + +message YourLibraryLabelAndImage { + string label = 1; + string image = 2; +} + +message YourLibraryPseudoPlaylistConfig { + YourLibraryLabelAndImage liked_songs = 1; + YourLibraryLabelAndImage your_episodes = 2; + YourLibraryLabelAndImage new_episodes = 3; + YourLibraryLabelAndImage local_files = 4; +} diff --git a/protocol/proto/your_library_request.proto b/protocol/proto/your_library_request.proto new file mode 100644 index 00000000..917a1add --- /dev/null +++ b/protocol/proto/your_library_request.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_pseudo_playlist_config.proto"; + +option optimize_for = CODE_SIZE; + +message YourLibraryRequestHeader { + bool remaining_entities = 9; +} + +message YourLibraryRequest { + YourLibraryRequestHeader header = 1; + YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 4; +} diff --git a/protocol/proto/your_library_response.proto b/protocol/proto/your_library_response.proto new file mode 100644 index 00000000..c354ff5b --- /dev/null +++ b/protocol/proto/your_library_response.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_decorated_entity.proto"; + +option optimize_for = CODE_SIZE; + +message YourLibraryResponseHeader { + int32 remaining_entities = 9; + bool is_loading = 12; + YourLibraryAvailableEntityTypes has = 13; + YourLibraryAvailableEntityTypes has_downloaded = 14; + string folder_name = 15; +} + +message YourLibraryResponse { + YourLibraryResponseHeader header = 1; + repeated YourLibraryDecoratedEntity entity = 2; + string error = 99; +} diff --git a/rustfmt.toml b/rustfmt.toml index aefd6aa8..32a9786f 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1 @@ -# max_width = 105 -reorder_imports = true -reorder_modules = true edition = "2018" diff --git a/src/main.rs b/src/main.rs index 0c542f6f..0aaa613b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,44 @@ -use futures_util::{future, FutureExt, StreamExt}; -use librespot_playback::player::PlayerEvent; +use std::{ + env, + fs::create_dir_all, + ops::RangeInclusive, + path::{Path, PathBuf}, + pin::Pin, + process::exit, + str::FromStr, + time::{Duration, Instant}, +}; + +use futures_util::StreamExt; use log::{error, info, trace, warn}; use sha1::{Digest, Sha1}; use thiserror::Error; use tokio::sync::mpsc::UnboundedReceiver; use url::Url; -use librespot::connect::spirc::Spirc; -use librespot::core::authentication::Credentials; -use librespot::core::cache::Cache; -use librespot::core::config::{ConnectConfig, DeviceType, SessionConfig}; -use librespot::core::session::Session; -use librespot::core::version; -use librespot::playback::audio_backend::{self, SinkBuilder, BACKENDS}; -use librespot::playback::config::{ - AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl, +use librespot::{ + connect::{config::ConnectConfig, spirc::Spirc}, + core::{ + authentication::Credentials, cache::Cache, config::DeviceType, version, Session, + SessionConfig, + }, + playback::{ + audio_backend::{self, SinkBuilder, BACKENDS}, + config::{ + AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl, + }, + dither, + mixer::{self, MixerConfig, MixerFn}, + player::{coefficient_to_duration, duration_to_coefficient, Player, PlayerEvent}, + }, }; -use librespot::playback::dither; + #[cfg(feature = "alsa-backend")] use librespot::playback::mixer::alsamixer::AlsaMixer; -use librespot::playback::mixer::{self, MixerConfig, MixerFn}; -use librespot::playback::player::{coefficient_to_duration, duration_to_coefficient, Player}; mod player_event_handler; use player_event_handler::{emit_sink_event, run_program_on_events}; -use std::env; -use std::ops::RangeInclusive; -use std::path::Path; -use std::pin::Pin; -use std::process::exit; -use std::str::FromStr; -use std::time::Duration; -use std::time::Instant; - fn device_id(name: &str) -> String { hex::encode(Sha1::digest(name.as_bytes())) } @@ -193,7 +198,6 @@ fn get_setup() -> Setup { const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive = 1..=1000; const AP_PORT: &str = "ap-port"; - const AUTOPLAY: &str = "autoplay"; const BACKEND: &str = "backend"; const BITRATE: &str = "bitrate"; const CACHE: &str = "cache"; @@ -223,11 +227,13 @@ fn get_setup() -> Setup { const NORMALISATION_RELEASE: &str = "normalisation-release"; const NORMALISATION_THRESHOLD: &str = "normalisation-threshold"; const ONEVENT: &str = "onevent"; + #[cfg(feature = "passthrough-decoder")] const PASSTHROUGH: &str = "passthrough"; const PASSWORD: &str = "password"; const PROXY: &str = "proxy"; const QUIET: &str = "quiet"; const SYSTEM_CACHE: &str = "system-cache"; + const TEMP_DIR: &str = "tmp"; const USERNAME: &str = "username"; const VERBOSE: &str = "verbose"; const VERSION: &str = "version"; @@ -236,7 +242,6 @@ fn get_setup() -> Setup { const ZEROCONF_PORT: &str = "zeroconf-port"; // Mostly arbitrary. - const AUTOPLAY_SHORT: &str = "A"; const AP_PORT_SHORT: &str = "a"; const BACKEND_SHORT: &str = "B"; const BITRATE_SHORT: &str = "b"; @@ -258,6 +263,7 @@ fn get_setup() -> Setup { const NAME_SHORT: &str = "n"; const DISABLE_DISCOVERY_SHORT: &str = "O"; const ONEVENT_SHORT: &str = "o"; + #[cfg(feature = "passthrough-decoder")] const PASSTHROUGH_SHORT: &str = "P"; const PASSWORD_SHORT: &str = "p"; const EMIT_SINK_EVENTS_SHORT: &str = "Q"; @@ -266,6 +272,7 @@ fn get_setup() -> Setup { const ALSA_MIXER_DEVICE_SHORT: &str = "S"; const ALSA_MIXER_INDEX_SHORT: &str = "s"; const ALSA_MIXER_CONTROL_SHORT: &str = "T"; + const TEMP_DIR_SHORT: &str = "t"; const NORMALISATION_ATTACK_SHORT: &str = "U"; const USERNAME_SHORT: &str = "u"; const VERSION_SHORT: &str = "V"; @@ -279,7 +286,7 @@ fn get_setup() -> Setup { const NORMALISATION_THRESHOLD_SHORT: &str = "Z"; const ZEROCONF_PORT_SHORT: &str = "z"; - // Options that have different desc's + // Options that have different descriptions // depending on what backends were enabled at build time. #[cfg(feature = "alsa-backend")] const MIXER_TYPE_DESC: &str = "Mixer to use {alsa|softvol}. Defaults to softvol."; @@ -366,16 +373,6 @@ fn get_setup() -> Setup { EMIT_SINK_EVENTS, "Run PROGRAM set by `--onevent` before the sink is opened and after it is closed.", ) - .optflag( - AUTOPLAY_SHORT, - AUTOPLAY, - "Automatically play similar songs when your music ends.", - ) - .optflag( - PASSTHROUGH_SHORT, - PASSTHROUGH, - "Pass a raw stream to the output. Only works with the pipe and subprocess backends.", - ) .optflag( ENABLE_VOLUME_NORMALISATION_SHORT, ENABLE_VOLUME_NORMALISATION, @@ -411,10 +408,16 @@ fn get_setup() -> Setup { "Displayed device type. Defaults to speaker.", "TYPE", ) + .optopt( + TEMP_DIR_SHORT, + TEMP_DIR, + "Path to a directory where files will be temporarily stored while downloading.", + "PATH", + ) .optopt( CACHE_SHORT, CACHE, - "Path to a directory where files will be cached.", + "Path to a directory where files will be cached after downloading.", "PATH", ) .optopt( @@ -562,6 +565,13 @@ fn get_setup() -> Setup { "PORT", ); + #[cfg(feature = "passthrough-decoder")] + opts.optflag( + PASSTHROUGH_SHORT, + PASSTHROUGH, + "Pass a raw stream to the output. Only works with the pipe and subprocess backends.", + ); + let args: Vec<_> = std::env::args_os() .filter_map(|s| match s.into_string() { Ok(valid) => Some(valid), @@ -988,6 +998,15 @@ fn get_setup() -> Setup { } }; + let tmp_dir = opt_str(TEMP_DIR).map_or(SessionConfig::default().tmp_dir, |p| { + let tmp_dir = PathBuf::from(p); + if let Err(e) = create_dir_all(&tmp_dir) { + error!("could not create or access specified tmp directory: {}", e); + exit(1); + } + tmp_dir + }); + let cache = { let volume_dir = opt_str(SYSTEM_CACHE) .or_else(|| opt_str(CACHE)) @@ -1236,38 +1255,30 @@ fn get_setup() -> Setup { .unwrap_or_default(); let has_volume_ctrl = !matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed); - let autoplay = opt_present(AUTOPLAY); ConnectConfig { name, device_type, initial_volume, has_volume_ctrl, - autoplay, } }; let session_config = SessionConfig { - user_agent: version::VERSION_STRING.to_string(), device_id: device_id(&connect_config.name), proxy: opt_str(PROXY).or_else(|| std::env::var("http_proxy").ok()).map( |s| { match Url::parse(&s) { Ok(url) => { if url.host().is_none() || url.port_or_known_default().is_none() { - error!("Invalid proxy url, only URLs on the format \"http://host:port\" are allowed"); - exit(1); - } - - if url.scheme() != "http" { - error!("Only unsecure http:// proxies are supported"); + error!("Invalid proxy url, only URLs on the format \"http(s)://host:port\" are allowed"); exit(1); } url }, Err(e) => { - error!("Invalid proxy URL: \"{}\", only URLs in the format \"http://host:port\" are allowed", e); + error!("Invalid proxy URL: \"{}\", only URLs in the format \"http(s)://host:port\" are allowed", e); exit(1); } } @@ -1282,6 +1293,8 @@ fn get_setup() -> Setup { exit(1); } }), + tmp_dir, + ..SessionConfig::default() }; let player_config = { @@ -1528,7 +1541,10 @@ fn get_setup() -> Setup { }, }; + #[cfg(feature = "passthrough-decoder")] let passthrough = opt_present(PASSTHROUGH); + #[cfg(not(feature = "passthrough-decoder"))] + let passthrough = false; PlayerConfig { bitrate, @@ -1585,7 +1601,9 @@ async fn main() { let mut player_event_channel: Option> = None; let mut auto_connect_times: Vec = vec![]; let mut discovery = None; - let mut connecting: Pin>> = Box::pin(future::pending()); + let mut connecting = false; + + let session = Session::new(setup.session_config.clone(), setup.cache.clone()); if setup.enable_discovery { let device_id = setup.session_config.device_id.clone(); @@ -1601,16 +1619,8 @@ async fn main() { } if let Some(credentials) = setup.credentials { - last_credentials = Some(credentials.clone()); - connecting = Box::pin( - Session::connect( - setup.session_config.clone(), - credentials, - setup.cache.clone(), - true, - ) - .fuse(), - ); + last_credentials = Some(credentials); + connecting = true; } else if discovery.is_none() { error!( "Discovery is unavailable and no credentials provided. Authentication is not possible." @@ -1632,19 +1642,16 @@ async fn main() { auto_connect_times.clear(); if let Some(spirc) = spirc.take() { - spirc.shutdown(); + if let Err(e) = spirc.shutdown() { + error!("error sending spirc shutdown message: {}", e); + } } if let Some(spirc_task) = spirc_task.take() { // Continue shutdown in its own task tokio::spawn(spirc_task); } - connecting = Box::pin(Session::connect( - setup.session_config.clone(), - credentials, - setup.cache.clone(), - true, - ).fuse()); + connecting = true; }, None => { error!("Discovery stopped unexpectedly"); @@ -1652,58 +1659,59 @@ async fn main() { } } }, - session = &mut connecting, if !connecting.is_terminated() => match session { - Ok((session,_)) => { - let mixer_config = setup.mixer_config.clone(); - let mixer = (setup.mixer)(mixer_config); - let player_config = setup.player_config.clone(); - let connect_config = setup.connect_config.clone(); + _ = async {}, if connecting && last_credentials.is_some() => { + let mixer_config = setup.mixer_config.clone(); + let mixer = (setup.mixer)(mixer_config); + let player_config = setup.player_config.clone(); + let connect_config = setup.connect_config.clone(); - let soft_volume = mixer.get_soft_volume(); - let format = setup.format; - let backend = setup.backend; - let device = setup.device.clone(); - let (player, event_channel) = - Player::new(player_config, session.clone(), soft_volume, move || { - (backend)(device, format) - }); + let soft_volume = mixer.get_soft_volume(); + let format = setup.format; + let backend = setup.backend; + let device = setup.device.clone(); + let (player, event_channel) = + Player::new(player_config, session.clone(), soft_volume, move || { + (backend)(device, format) + }); - if setup.emit_sink_events { - if let Some(player_event_program) = setup.player_event_program.clone() { - player.set_sink_event_callback(Some(Box::new(move |sink_status| { - match emit_sink_event(sink_status, &player_event_program) { - Ok(e) if e.success() => (), - Ok(e) => { - if let Some(code) = e.code() { - warn!("Sink event program returned exit code {}", code); - } else { - warn!("Sink event program returned failure"); - } - }, - Err(e) => { - warn!("Emitting sink event failed: {}", e); - }, - } - }))); - } - }; + if setup.emit_sink_events { + if let Some(player_event_program) = setup.player_event_program.clone() { + player.set_sink_event_callback(Some(Box::new(move |sink_status| { + match emit_sink_event(sink_status, &player_event_program) { + Ok(e) if e.success() => (), + Ok(e) => { + if let Some(code) = e.code() { + warn!("Sink event program returned exit code {}", code); + } else { + warn!("Sink event program returned failure"); + } + }, + Err(e) => { + warn!("Emitting sink event failed: {}", e); + }, + } + }))); + } + }; - let (spirc_, spirc_task_) = Spirc::new(connect_config, session, player, mixer); + let (spirc_, spirc_task_) = match Spirc::new(connect_config, session.clone(), last_credentials.clone().unwrap(), player, mixer).await { + Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_), + Err(e) => { + error!("could not initialize spirc: {}", e); + exit(1); + } + }; + spirc = Some(spirc_); + spirc_task = Some(Box::pin(spirc_task_)); + player_event_channel = Some(event_channel); - spirc = Some(spirc_); - spirc_task = Some(Box::pin(spirc_task_)); - player_event_channel = Some(event_channel); - }, - Err(e) => { - error!("Connection failed: {}", e); - exit(1); - } + connecting = false; }, _ = async { if let Some(task) = spirc_task.as_mut() { task.await; } - }, if spirc_task.is_some() => { + }, if spirc_task.is_some() && !connecting => { spirc_task = None; warn!("Spirc shut down unexpectedly"); @@ -1714,15 +1722,9 @@ async fn main() { }; match last_credentials.clone() { - Some(credentials) if !reconnect_exceeds_rate_limit() => { + Some(_) if !reconnect_exceeds_rate_limit() => { auto_connect_times.push(Instant::now()); - - connecting = Box::pin(Session::connect( - setup.session_config.clone(), - credentials, - setup.cache.clone(), - true - ).fuse()); + connecting = true; }, _ => { error!("Spirc shut down too often. Not reconnecting automatically."); @@ -1776,7 +1778,9 @@ async fn main() { // Shutdown spirc if necessary if let Some(spirc) = spirc { - spirc.shutdown(); + if let Err(e) = spirc.shutdown() { + error!("error sending spirc shutdown message: {}", e); + } if let Some(mut spirc_task) = spirc_task { tokio::select! { diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 785290ed..99b1645d 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -1,14 +1,16 @@ -use librespot::playback::player::PlayerEvent; -use librespot::playback::player::SinkStatus; use log::info; + +use std::{ + collections::HashMap, + io::{Error, ErrorKind, Result}, + process::{Command, ExitStatus}, +}; + use tokio::process::{Child as AsyncChild, Command as AsyncCommand}; -use std::collections::HashMap; -use std::io; -use std::io::{Error, ErrorKind}; -use std::process::{Command, ExitStatus}; +use librespot::playback::player::{PlayerEvent, SinkStatus}; -pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option> { +pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option> { let mut env_vars = HashMap::new(); match event { PlayerEvent::Changed { @@ -18,20 +20,14 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option { return Some(Err(Error::new( ErrorKind::InvalidData, - format!( - "PlayerEvent::Changed: Invalid old track id: {}", - e.utf8_error() - ), + format!("PlayerEvent::Changed: Invalid old track id: {}", e), ))) } Ok(old_id) => match new_track_id.to_base62() { Err(e) => { return Some(Err(Error::new( ErrorKind::InvalidData, - format!( - "PlayerEvent::Changed: Invalid new track id: {}", - e.utf8_error() - ), + format!("PlayerEvent::Changed: Invalid old track id: {}", e), ))) } Ok(new_id) => { @@ -45,7 +41,7 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option { return Some(Err(Error::new( ErrorKind::InvalidData, - format!("PlayerEvent::Started: Invalid track id: {}", e.utf8_error()), + format!("PlayerEvent::Started: Invalid track id: {}", e), ))) } Ok(id) => { @@ -57,7 +53,7 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option { return Some(Err(Error::new( ErrorKind::InvalidData, - format!("PlayerEvent::Stopped: Invalid track id: {}", e.utf8_error()), + format!("PlayerEvent::Stopped: Invalid track id: {}", e), ))) } Ok(id) => { @@ -74,7 +70,7 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option { return Some(Err(Error::new( ErrorKind::InvalidData, - format!("PlayerEvent::Playing: Invalid track id: {}", e.utf8_error()), + format!("PlayerEvent::Playing: Invalid track id: {}", e), ))) } Ok(id) => { @@ -93,7 +89,7 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option { return Some(Err(Error::new( ErrorKind::InvalidData, - format!("PlayerEvent::Paused: Invalid track id: {}", e.utf8_error()), + format!("PlayerEvent::Paused: Invalid track id: {}", e), ))) } Ok(id) => { @@ -107,10 +103,7 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option { return Some(Err(Error::new( ErrorKind::InvalidData, - format!( - "PlayerEvent::Preloading: Invalid track id: {}", - e.utf8_error() - ), + format!("PlayerEvent::Preloading: Invalid track id: {}", e), ))) } Ok(id) => { @@ -135,7 +128,7 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option io::Result { +pub fn emit_sink_event(sink_status: SinkStatus, onevent: &str) -> Result { let mut env_vars = HashMap::new(); env_vars.insert("PLAYER_EVENT", "sink".to_string()); let sink_status = match sink_status {