diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6e447ff9..30848c9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,32 +31,20 @@ on: "!LICENSE", "!*.sh", ] - schedule: - # Run CI every week - - cron: "00 01 * * 0" 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: @@ -66,12 +54,11 @@ jobs: toolchain: - 1.48 # 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 +93,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.48 # MSRV (Minimum supported rust version) + - stable steps: - name: Checkout code uses: actions/checkout@v2 @@ -153,20 +143,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.48 # MSRV (Minimum supported rust version) + - stable steps: - name: Checkout code uses: actions/checkout@v2 @@ -197,3 +189,67 @@ jobs: run: cargo install cross || true - name: Build run: cross build --locked --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/CHANGELOG.md b/CHANGELOG.md index ceb63541..c5757aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0. ## [Unreleased] + +### Changed +- [main] Enforce reasonable ranges for option values (breaking). +- [main] Don't evaluate options that would otherwise have no effect. +- [playback] `alsa`: Improve `--device ?` functionality for the alsa backend. +- [contrib] Hardened security of the systemd service units +- [main] Verbose logging mode (`-v`, `--verbose`) now logs all parsed environment variables and command line arguments (credentials are redacted). + +### Added +- [cache] Add `disable-credential-cache` flag (breaking). +- [main] Use different option descriptions and error messages based on what backends are enabled at build time. +- [main] Add a `-q`, `--quiet` option that changes the logging level to warn. +- [main] Add a short name for every flag and option. +- [main] Add the ability to parse environment variables. + +### Fixed +- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given. +- [main] Don't panic when parsing options. Instead list valid values and exit. + +### Removed +- [playback] `alsamixer`: previously deprecated option `mixer-card` has been removed. +- [playback] `alsamixer`: previously deprecated option `mixer-name` has been removed. +- [playback] `alsamixer`: previously deprecated option `mixer-index` has been removed. + +## [0.3.1] - 2021-10-24 + +### Changed +- Include build profile in the displayed version information +- [playback] Improve dithering CPU usage by about 33% + +### Fixed +- [connect] Partly fix behavior after last track of an album/playlist + +## [0.3.0] - 2021-10-13 + ### Added - [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`. - [playback] Add support for dithering with `--dither` for lower requantization error (breaking) - [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves - [playback] `alsamixer`: support for querying dB range from Alsa softvol - [playback] Add `--format F64` (supported by Alsa and GStreamer only) +- [playback] Add `--normalisation-gain-type auto` that switches between album and track automatically ### Changed -- [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking) +- [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `DecoderError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking) - [audio, playback] Use `Duration` for time constants and functions (breaking) - [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate - [connect] Synchronize player volume with mixer volume on playback @@ -22,20 +58,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic` - [playback] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking) - [playback] `alsamixer`: complete rewrite (breaking) -- [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise +- [playback] `alsamixer`: query card dB range for the volume control unless specified otherwise - [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise - [playback] `player`: consider errors in `sink.start`, `sink.stop` and `sink.write` fatal and `exit(1)` (breaking) +- [playback] `player`: make `convert` and `decoder` public so you can implement your own `Sink` +- [playback] `player`: update default normalisation threshold to -2 dBFS +- [playback] `player`: default normalisation type is now `auto` ### Deprecated - [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate +- [playback] `alsamixer`: renamed `mixer-card` to `alsa-mixer-device` +- [playback] `alsamixer`: renamed `mixer-name` to `alsa-mixer-control` +- [playback] `alsamixer`: renamed `mixer-index` to `alsa-mixer-index` ### Removed - [connect] Removed no-op mixer started/stopped logic (breaking) - [playback] Removed `with-vorbis` and `with-tremor` features -- [playback] `alsamixer`: removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead +- [playback] `alsamixer`: removed `--mixer-linear-volume` option, now that `--volume-ctrl {linear|log}` work as expected on Alsa ### Fixed - [connect] Fix step size on volume up/down events +- [connect] Fix looping back to the first track after the last track of an album or playlist - [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream - [playback] Fix `log` and `cubic` volume controls to be mute at zero volume - [playback] Fix `S24_3` format on big-endian systems @@ -43,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected - [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness - [playback] `alsa`: revert buffer size to ~500 ms -- [playback] `alsa`, `pipe`: better error handling +- [playback] `alsa`, `pipe`, `pulseaudio`: better error handling +- [metadata] Skip tracks whose Spotify ID's can't be found (e.g. local files, which aren't supported) ## [0.2.0] - 2021-05-04 @@ -59,7 +103,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0] - 2019-11-06 -[unreleased]: https://github.com/librespot-org/librespot/compare/v0.2.0..HEAD +[unreleased]: https://github.com/librespot-org/librespot/compare/v0.3.1..HEAD +[0.3.1]: https://github.com/librespot-org/librespot/compare/v0.3.0..v0.3.1 +[0.3.0]: https://github.com/librespot-org/librespot/compare/v0.2.0..v0.3.0 [0.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6..v0.2.0 [0.1.6]: https://github.com/librespot-org/librespot/compare/v0.1.5..v0.1.6 [0.1.5]: https://github.com/librespot-org/librespot/compare/v0.1.3..v0.1.5 diff --git a/COMPILING.md b/COMPILING.md index 8748cd0c..39ae20cc 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -5,20 +5,15 @@ In order to compile librespot, you will first need to set up a suitable Rust build environment, with the necessary dependencies installed. You will need to have a C compiler, Rust, and the development libraries for the audio backend(s) you want installed. These instructions will walk you through setting up a simple build environment. ### Install Rust -The easiest, and recommended way to get Rust is to use [rustup](https://rustup.rs). On Unix/MacOS You can install `rustup` with this command: - -```bash -curl https://sh.rustup.rs -sSf | sh -``` - -Follow any prompts it gives you to install Rust. Once that’s done, Rust's standard tools should be setup and ready to use. +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.48, you can find the current minimum version specified in the `.github/workflow/test.yml` file.* #### Additional Rust tools - `rustfmt` -To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust-lang/rustfmt), which is installed by default with `rustup` these days, else it can be installed manually with: +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: ```bash rustup component add rustfmt +rustup component add clippy ``` Using `rustfmt` is not optional, as our CI checks against this repo's rules. @@ -43,12 +38,13 @@ Depending on the chosen backend, specific development libraries are required. |--------------------|------------------------------|-----------------------------------|-------------| |Rodio (default) | `libasound2-dev` | `alsa-lib-devel` | | |ALSA | `libasound2-dev, pkg-config` | `alsa-lib-devel` | | +|GStreamer | `gstreamer1.0-plugins-base libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good libgstreamer-plugins-good1.0-dev` | `gstreamer1 gstreamer1-devel gstreamer1-plugins-base-devel gstreamer1-plugins-good` | `gstreamer gst-devtools gst-plugins-base gst-plugins-good` | |PortAudio | `portaudio19-dev` | `portaudio-devel` | `portaudio` | |PulseAudio | `libpulse-dev` | `pulseaudio-libs-devel` | | -|JACK | `libjack-dev` | `jack-audio-connection-kit-devel` | | -|JACK over Rodio | `libjack-dev` | `jack-audio-connection-kit-devel` | - | -|SDL | `libsdl2-dev` | `SDL2-devel` | | -|Pipe | - | - | - | +|JACK | `libjack-dev` | `jack-audio-connection-kit-devel` | `jack` | +|JACK over Rodio | `libjack-dev` | `jack-audio-connection-kit-devel` | `jack` | +|SDL | `libsdl2-dev` | `SDL2-devel` | `sdl2` | +|Pipe & subprocess | - | - | - | ###### For example, to build an ALSA based backend, you would need to run the following to install the required dependencies: @@ -68,7 +64,6 @@ The recommended method is to first fork the repo, so that you have a copy that y ```bash git clone git@github.com:YOURUSERNAME/librespot.git -cd librespot ``` ## Compiling & Running @@ -109,7 +104,9 @@ cargo build --no-default-features --features "alsa-backend" Assuming you just compiled a ```debug``` build, you can run librespot with the following command: ```bash -./target/debug/librespot -n Librespot +./target/debug/librespot ``` There are various runtime options, documented in the wiki, and visible by running librespot with the ```-h``` argument. + +Note that debug builds may cause buffer underruns and choppy audio when dithering is enabled (which it is by default). You can disable dithering with ```--dither none```. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3395529c..1ba24393 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,10 +8,12 @@ If you have encountered a bug, please report it, as we rely on user reports to f Please also make sure that your issues are helpful. To ensure that your issue is helpful, please read over this brief checklist to avoid the more common pitfalls: - - Please take a moment to search/read previous similar issues to ensure you aren’t posting a duplicate. Duplicates will be closed immediately. - - Please include a clear description of what the issue is. Issues with descriptions such as ‘It hangs after 40 minutes’ will be closed immediately. - - Please include, where possible, steps to reproduce the bug, along with any other material that is related to the bug. For example, if librespot consistently crashes when you try to play a song, please include the Spotify URI of that song. This can be immensely helpful in quickly pinpointing and resolving issues. - - Lastly, and perhaps most importantly, please include a backtrace where possible. Recent versions of librespot should produce these automatically when it crashes, and print them to the console, but in some cases, you may need to run ‘export RUST_BACKTRACE=full’ before running librespot to enable backtraces. +- Please take a moment to search/read previous similar issues to ensure you aren’t posting a duplicate. Duplicates will be closed immediately. +- Please include a clear description of what the issue is. Issues with descriptions such as ‘It hangs after 40 minutes’ will be closed immediately. +- Please include, where possible, steps to reproduce the bug, along with any other material that is related to the bug. For example, if librespot consistently crashes when you try to play a song, please include the Spotify URI of that song. This can be immensely helpful in quickly pinpointing and resolving issues. +- Please be alert and respond to questions asked by any project members. Stale issues will be closed. +- When your issue concerns audio playback, please first make sure that your audio system is set up correctly and can play audio from other applications. This project aims to provide correct audio backends, not to provide Linux support to end users. +- Lastly, and perhaps most importantly, please include a backtrace where possible. Recent versions of librespot should produce these automatically when it crashes, and print them to the console, but in some cases, you may need to run ‘export RUST_BACKTRACE=full’ before running librespot to enable backtraces. ## Contributing Code @@ -29,20 +31,25 @@ In order to prepare for a PR, you will need to do a couple of things first: Make any changes that you are going to make to the code, but do not commit yet. -Unless your changes are negligible, please add an entry in the "Unreleased" section of `CHANGELOG.md`. Refer to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) for instructions on how this entry should look like. +Unless your changes are negligible, please add an entry in the "Unreleased" section of `CHANGELOG.md`. Refer to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) for instructions on how this entry should look like. If your changes break the API such that downstream packages that depend on librespot need to update their source to still compile, you should mark your changes as `(breaking)`. Make sure that the code is correctly formatted by running: ```bash -cargo +stable fmt --all +cargo fmt --all ``` -This command runs the previously installed stable version of ```rustfmt```, a code formatting tool that will automatically correct any formatting that you have used that does not conform with the librespot code style. Once that command has run, you will need to rebuild the project: +This command runs ```rustfmt```, a code formatting tool that will automatically correct any formatting that you have used that does not conform with the librespot code style. Once that command has run, you will need to rebuild the project: ```bash cargo build ``` -Once it has built, and you have confirmed there are no warnings or errors, you should commit your changes. +Once it has built, check for common code mistakes by running: +```bash +cargo clippy +``` + +Once you have confirmed there are no warnings or errors, you should commit your changes. ```bash git commit -a -m "My fancy fix" diff --git a/Cargo.lock b/Cargo.lock index 37cbae56..81f083ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,22 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "aes" version = "0.6.0" @@ -76,15 +93,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.40" +version = "1.0.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" +checksum = "8b26702f315f53b6071259e15dd9d64528213b44d61de1ec926eca7715d62203" [[package]] name = "async-trait" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b98e84bbb4cbcdd97da190ba0c58a1bb0de2c1fdf67d159e192ed766aeca722" +checksum = "44318e776df68115a881de9a8fd1b9e53368d7a4a5ce4cc48517da3393233a5e" dependencies = [ "proc-macro2", "quote", @@ -108,6 +125,21 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "backtrace" +version = "0.3.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321629d8ba6513061f26707241fa9bc89524ff1cd7a915a97ef0c62c666ce1b6" +dependencies = [ + "addr2line", + "cc", + "cfg-if 1.0.0", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + [[package]] name = "base64" version = "0.13.0" @@ -135,9 +167,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "1.2.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "block-buffer" @@ -150,9 +182,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.6.1" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" +checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c" [[package]] name = "byteorder" @@ -162,15 +194,15 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" [[package]] name = "cc" -version = "1.0.68" +version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a72c244c1ff497a746a7e1fb3d14bd08420ecda70c8f25c7112f2781652d787" +checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee" dependencies = [ "jobserver", ] @@ -226,41 +258,40 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "853eda514c284c2287f4bf20ae614f8781f40a81d32ecda6e91449304dfe077c" +checksum = "fa66045b9cb23c2e9c1520732030608b02ee07e5cfaa5a521ec15ded7fa24c90" dependencies = [ "glob", "libc", - "libloading 0.7.0", -] - -[[package]] -name = "colored" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ffc801dacf156c5854b9df4f425a626539c3a6ef7893cc0c5084a23f0b6c59" -dependencies = [ - "atty", - "lazy_static", - "winapi", + "libloading 0.7.2", ] [[package]] name = "combine" -version = "4.5.2" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4369b5e4c0cddf64ad8981c0111e7df4f7078f4d6ba98fb31f2e17c4c57b7e" +checksum = "b2b2f5d0ee456f3928812dfc8c6d9a1d592b98678f6d56db9b0cd2b7bc6c8db5" dependencies = [ "bytes", "memchr", ] [[package]] -name = "core-foundation-sys" -version = "0.6.2" +name = "core-foundation" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ca8a5221364ef15ce201e8ed2f609fc312682a8f4e0e3d4aa5879764e0fa3b" +checksum = "6888e10551bb93e424d8df1d07f1a8b4fceb0001a3a4b048bfc47554946f47b3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" [[package]] name = "coreaudio-rs" @@ -283,21 +314,21 @@ dependencies = [ [[package]] name = "cpal" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8351ddf2aaa3c583fa388029f8b3d26f3c7035a20911fdd5f2e2ed7ab57dad25" +checksum = "98f45f0a21f617cd2c788889ef710b63f075c949259593ea09c826f1e47a2418" dependencies = [ "alsa", "core-foundation-sys", "coreaudio-rs", - "jack 0.6.6", + "jack", "jni", "js-sys", "lazy_static", "libc", "mach", - "ndk", - "ndk-glue", + "ndk 0.3.0", + "ndk-glue 0.3.0", "nix", "oboe", "parking_lot", @@ -309,23 +340,32 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.1.4" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed00c67cb5d0a7d64a44f6ad2668db7e7530311dd53ea79bcd4fb022c64911c8" +checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" dependencies = [ "libc", ] [[package]] name = "crypto-mac" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" +checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" dependencies = [ "generic-array", "subtle", ] +[[package]] +name = "ct-logs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a816186fa68d9e426e3cb4ae4dff1fcd8e4a2c34b781bf7a822574a0d0aac8" +dependencies = [ + "sct", +] + [[package]] name = "ctr" version = "0.6.0" @@ -408,9 +448,9 @@ checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" [[package]] name = "env_logger" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" dependencies = [ "atty", "humantime", @@ -419,6 +459,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "fixedbitset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d" + [[package]] name = "fnv" version = "1.0.7" @@ -437,9 +483,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7e43a803dae2fa37c1f6a8fe121e1f7bf9548b4dfc0522a42f34145dadfc27" +checksum = "a12aa0eb539080d55c3f2d45a67c3b58b6b0773c1a3ca2dfec66d58c97fd66ca" dependencies = [ "futures-channel", "futures-core", @@ -452,9 +498,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2" +checksum = "5da6ba8c3bb3c165d3c7319fc1cc8304facf1fb8db99c5de877183c08a273888" dependencies = [ "futures-core", "futures-sink", @@ -462,15 +508,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0402f765d8a89a26043b889b26ce3c4679d268fa6bb22cd7c6aad98340e179d1" +checksum = "88d1c26957f23603395cd326b0ffe64124b818f4449552f960d815cfba83a53d" [[package]] name = "futures-executor" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "badaa6a909fac9e7236d0620a2f57f7664640c56575b71a7552fbd68deafab79" +checksum = "45025be030969d763025784f7f355043dc6bc74093e4ecc5000ca4dc50d8745c" dependencies = [ "futures-core", "futures-task", @@ -479,15 +525,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acc499defb3b348f8d8f3f66415835a9131856ff7714bf10dadfc4ec4bdb29a1" +checksum = "522de2a0fe3e380f1bc577ba0474108faf3f6b18321dbf60b3b9c39a75073377" [[package]] name = "futures-macro" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c40298486cdf52cc00cd6d6987892ba502c7656a16a4192a9992b1ccedd121" +checksum = "18e4a4b95cea4b4ccbcf1c5675ca7c4ee4e9e75eb79944d07defde18068f79bb" dependencies = [ "autocfg", "proc-macro-hack", @@ -498,21 +544,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a57bead0ceff0d6dde8f465ecd96c9338121bb7717d3e7b108059531870c4282" +checksum = "36ea153c13024fe480590b3e3d4cad89a0cfacecc24577b68f86c6ced9c2bc11" [[package]] name = "futures-task" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a16bef9fc1a4dddb5bee51c989e3fbba26569cbb0e31f5b303c184e3dd33dae" +checksum = "1d3d00f4eddb73e498a54394f228cd55853bdf059259e8e7bc6e69d408892e99" [[package]] name = "futures-util" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967" +checksum = "36568465210a3a6ee45e1f165136d68671471a501e632e9a98d96872222b5481" dependencies = [ "autocfg", "futures-channel", @@ -559,6 +605,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "gimli" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4" + [[package]] name = "glib" version = "0.10.3" @@ -587,7 +639,7 @@ dependencies = [ "anyhow", "heck", "itertools", - "proc-macro-crate", + "proc-macro-crate 0.1.5", "proc-macro-error", "proc-macro2", "quote", @@ -720,25 +772,44 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.9.1" +name = "h2" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +checksum = "8f072413d126e57991455e0a922b31e4c8ba7c2ffbebf6b78b4f8521397d65cd" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" [[package]] name = "headers" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0b7591fb62902706ae8e7aaff416b1b0fa2c0fd0878b46dc13baa3712d8a855" +checksum = "a4c4eb0471fcb85846d8b0690695ef354f9afb11cb03cac2e1d7c9253351afb0" dependencies = [ "base64", "bitflags", "bytes", "headers-core", "http", + "httpdate", "mime", "sha-1", - "time", ] [[package]] @@ -752,18 +823,18 @@ dependencies = [ [[package]] name = "heck" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" dependencies = [ "unicode-segmentation", ] [[package]] name = "hermit-abi" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" dependencies = [ "libc", ] @@ -797,9 +868,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527e8c9ac747e28542699a951517aa9a6945af506cd1f2e1b53a576c17b6cc11" +checksum = "1323096b05d41827dadeaee54c9981958c0f94e670bc94ed80037d1a7b8b186b" dependencies = [ "bytes", "fnv", @@ -808,9 +879,9 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60daa14be0e0786db0f03a9e57cb404c9d756eed2b6c62b9ea98ec5743ec75a9" +checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6" dependencies = [ "bytes", "http", @@ -819,15 +890,15 @@ dependencies = [ [[package]] name = "httparse" -version = "1.4.1" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a87b616e37e93c22fb19bcd386f02f3af5ea98a25670ad0fce773de23c5e68" +checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503" [[package]] name = "httpdate" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "humantime" @@ -837,20 +908,21 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.8" +version = "0.14.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3f71a7eea53a3f8257a7b4795373ff886397178cd634430ea94e12d7fe4fe34" +checksum = "b7ec3e62bdc98a2f0393a5048e4c30ef659440ea6e0e572965103e72bd836f55" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "httparse", "httpdate", "itoa", - "pin-project", + "pin-project-lite", "socket2", "tokio", "tower-service", @@ -869,8 +941,29 @@ dependencies = [ "headers", "http", "hyper", + "hyper-rustls", + "rustls-native-certs", "tokio", + "tokio-rustls", "tower-service", + "webpki", +] + +[[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", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "webpki", ] [[package]] @@ -892,9 +985,9 @@ dependencies = [ [[package]] name = "if-addrs" -version = "0.6.5" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28538916eb3f3976311f5dfbe67b5362d0add1293d0a9cad17debf86f8e3aa48" +checksum = "2273e421f7c4f0fc99e1934fe4776f59d8df2972f4199d703fc0da9f2a9f73de" dependencies = [ "if-addrs-sys", "libc", @@ -913,9 +1006,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3" +checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" dependencies = [ "autocfg", "hashbrown", @@ -932,9 +1025,9 @@ dependencies = [ [[package]] name = "instant" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ "cfg-if 1.0.0", ] @@ -950,32 +1043,21 @@ dependencies = [ [[package]] name = "itoa" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" [[package]] name = "jack" -version = "0.6.6" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2deb4974bd7e6b2fb7784f27fa13d819d11292b3b004dce0185ec08163cf686a" -dependencies = [ - "bitflags", - "jack-sys", - "lazy_static", - "libc", -] - -[[package]] -name = "jack" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e720259b4a3e1f33cba335ca524a99a5f2411d405b05f6405fadd69269e2db" +checksum = "d79b205ea723e478eb31a91dcdda100912c69cc32992eb7ba26ec0bbae7bebe4" dependencies = [ "bitflags", "jack-sys", "lazy_static", "libc", + "log", ] [[package]] @@ -991,9 +1073,9 @@ dependencies = [ [[package]] name = "jni" -version = "0.18.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24967112a1e4301ca5342ea339763613a37592b8a6ce6cf2e4494537c7a42faf" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" dependencies = [ "cesu8", "combine", @@ -1011,18 +1093,18 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.22" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972f5ae5d1cb9c6ae417789196c803205313edde988685da5e3aae0827b9e7fd" +checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" dependencies = [ "libc", ] [[package]] name = "js-sys" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bdfbace3a0e81a4253f73b49e960b053e396a11012cbd49b9b74d6a2b67062" +checksum = "7cc9ffccd38c451a86bf13657df244e9c3f37493cce8e5e21e940963777acc84" dependencies = [ "wasm-bindgen", ] @@ -1052,9 +1134,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.95" +version = "0.2.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "789da6d93f1b866ffe175afc5322a4d76c038605a1c3319bb57b06967ca98a36" +checksum = "f98a04dce437184842841303488f70d0188c5f51437d2a834dc097eafa909a01" [[package]] name = "libloading" @@ -1068,9 +1150,9 @@ dependencies = [ [[package]] name = "libloading" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f84d96438c15fcd6c3f244c8fce01d1e2b9c6b5623e9c711dc9286d8fc92d6a" +checksum = "afe203d669ec979b7128619bae5a63b7b42e9203c1b29146079ee05e2f604b52" dependencies = [ "cfg-if 1.0.0", "winapi", @@ -1084,9 +1166,9 @@ checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a" [[package]] name = "libmdns" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98477a6781ae1d6a1c2aeabfd2e23353a75fe8eb7c2545f6ed282ac8f3e2fc53" +checksum = "fac185a4d02e873c6d1ead59d674651f8ae5ec23ffe1637bee8de80665562a6a" dependencies = [ "byteorder", "futures-util", @@ -1102,9 +1184,9 @@ dependencies = [ [[package]] name = "libpulse-binding" -version = "2.23.1" +version = "2.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db951f37898e19a6785208e3a290261e0f1a8e086716be596aaad684882ca8e3" +checksum = "86835d7763ded6bc16b6c0061ec60214da7550dfcd4ef93745f6f0096129676a" dependencies = [ "bitflags", "libc", @@ -1116,9 +1198,9 @@ dependencies = [ [[package]] name = "libpulse-simple-binding" -version = "2.23.0" +version = "2.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a574975292db859087c3957b9182f7d53278553f06bddaa2099c90e4ac3a0ee0" +checksum = "d6a22538257c4d522bea6089d6478507f5d2589ea32150e20740aaaaaba44590" dependencies = [ "libpulse-binding", "libpulse-simple-sys", @@ -1127,9 +1209,9 @@ dependencies = [ [[package]] name = "libpulse-simple-sys" -version = "1.16.1" +version = "1.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "468cf582b7b022c0d1b266fefc7fc8fa7b1ddcb61214224f2f105c95a9c2d5c1" +checksum = "7c73f96f9ca34809692c4760cfe421225860aa000de50edab68a16221fd27cc1" dependencies = [ "libpulse-sys", "pkg-config", @@ -1137,9 +1219,9 @@ dependencies = [ [[package]] name = "libpulse-sys" -version = "1.18.0" +version = "1.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf17e9832643c4f320c42b7d78b2c0510f45aa5e823af094413b94e45076ba82" +checksum = "991e6bd0efe2a36e6534e136e7996925e4c1a8e35b7807fe533f2beffff27c30" dependencies = [ "libc", "num-derive", @@ -1150,7 +1232,7 @@ dependencies = [ [[package]] name = "librespot" -version = "0.2.0" +version = "0.3.1" dependencies = [ "base64", "env_logger", @@ -1175,21 +1257,26 @@ dependencies = [ [[package]] name = "librespot-audio" -version = "0.2.0" +version = "0.3.1" dependencies = [ "aes-ctr", "byteorder", "bytes", + "futures-core", + "futures-executor", "futures-util", + "hyper", "librespot-core", "log", + "parking_lot", "tempfile", + "thiserror", "tokio", ] [[package]] name = "librespot-connect" -version = "0.2.0" +version = "0.3.1" dependencies = [ "form_urlencoded", "futures-util", @@ -1202,18 +1289,21 @@ dependencies = [ "rand", "serde", "serde_json", + "thiserror", "tokio", "tokio-stream", ] [[package]] name = "librespot-core" -version = "0.2.0" +version = "0.3.1" dependencies = [ "aes", "base64", "byteorder", "bytes", + "chrono", + "dns-sd", "env_logger", "form_urlencoded", "futures-core", @@ -1223,6 +1313,7 @@ dependencies = [ "httparse", "hyper", "hyper-proxy", + "hyper-rustls", "librespot-protocol", "log", "num", @@ -1231,10 +1322,14 @@ dependencies = [ "num-integer", "num-traits", "once_cell", + "parking_lot", "pbkdf2", "priority-queue", "protobuf", + "quick-xml", "rand", + "rustls", + "rustls-native-certs", "serde", "serde_json", "sha-1", @@ -1251,7 +1346,7 @@ dependencies = [ [[package]] name = "librespot-discovery" -version = "0.2.0" +version = "0.3.1" dependencies = [ "aes-ctr", "base64", @@ -1260,6 +1355,7 @@ dependencies = [ "form_urlencoded", "futures", "futures-core", + "futures-util", "hex", "hmac", "hyper", @@ -1269,26 +1365,29 @@ dependencies = [ "rand", "serde_json", "sha-1", - "simple_logger", "thiserror", "tokio", ] [[package]] name = "librespot-metadata" -version = "0.2.0" +version = "0.3.1" dependencies = [ "async-trait", "byteorder", + "bytes", + "chrono", "librespot-core", "librespot-protocol", "log", "protobuf", + "thiserror", + "uuid", ] [[package]] name = "librespot-playback" -version = "0.2.0" +version = "0.3.1" dependencies = [ "alsa", "byteorder", @@ -1298,7 +1397,7 @@ dependencies = [ "glib", "gstreamer", "gstreamer-app", - "jack 0.7.1", + "jack", "lewton", "libpulse-binding", "libpulse-simple-binding", @@ -1320,7 +1419,7 @@ dependencies = [ [[package]] name = "librespot-protocol" -version = "0.2.0" +version = "0.3.1" dependencies = [ "glob", "protobuf", @@ -1329,9 +1428,9 @@ dependencies = [ [[package]] name = "lock_api" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb" +checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" dependencies = [ "scopeguard", ] @@ -1362,15 +1461,15 @@ checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" [[package]] name = "matches" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" [[package]] name = "memchr" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" [[package]] name = "mime" @@ -1379,10 +1478,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" [[package]] -name = "mio" -version = "0.7.11" +name = "miniz_oxide" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf80d3e903b34e0bd7282b218398aec54e082c840d9baf8339e0080a0c542956" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg", +] + +[[package]] +name = "mio" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" dependencies = [ "libc", "log", @@ -1427,6 +1536,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ndk" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64d6af06fde0e527b1ba5c7b79a6cc89cfc46325b0b2887dffe8f70197e0c3c" +dependencies = [ + "bitflags", + "jni-sys", + "ndk-sys", + "num_enum", + "thiserror", +] + [[package]] name = "ndk-glue" version = "0.3.0" @@ -1436,7 +1558,21 @@ dependencies = [ "lazy_static", "libc", "log", - "ndk", + "ndk 0.3.0", + "ndk-macro", + "ndk-sys", +] + +[[package]] +name = "ndk-glue" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e9e94628f24e7a3cb5b96a2dc5683acd9230bf11991c2a1677b87695138420" +dependencies = [ + "lazy_static", + "libc", + "log", + "ndk 0.4.0", "ndk-macro", "ndk-sys", ] @@ -1448,7 +1584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05d1c6307dc424d0f65b9b06e94f88248e6305726b14729fd67a5e47b2dc481d" dependencies = [ "darling", - "proc-macro-crate", + "proc-macro-crate 0.1.5", "proc-macro2", "quote", "syn", @@ -1456,9 +1592,9 @@ dependencies = [ [[package]] name = "ndk-sys" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c44922cb3dbb1c70b5e5f443d63b64363a898564d739ba5198e3a9138442868d" +checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121" [[package]] name = "nix" @@ -1507,9 +1643,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d047c1062aa51e256408c560894e5251f08925980e53cf1aa5bd00eec6512" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" dependencies = [ "autocfg", "num-integer", @@ -1603,9 +1739,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.5.1" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "226b45a5c2ac4dd696ed30fa6b94b057ad909c7b7fc2e0d0808192bced894066" +checksum = "3f9bd055fb730c4f8f4f57d45d35cd6b3f0980535b056dc7ff119cee6a66ed6f" dependencies = [ "derivative", "num_enum_derive", @@ -1613,25 +1749,34 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.5.1" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c0fd9eba1d5db0994a239e09c1be402d35622277e35468ba891aa5e3188ce7e" +checksum = "486ea01961c4a818096de679a8b740b26d9033146ac5291b1c98557658f8cdd9" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.1.0", "proc-macro2", "quote", "syn", ] [[package]] -name = "oboe" -version = "0.4.2" +name = "object" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa187b38ae20374617b7ad418034ed3dc90ac980181d211518bd03537ae8f8d" +checksum = "67ac1d3f9a1d3616fd9a60c8d74296f22406a238b6a72f5cc1e6f314df4ffbf9" +dependencies = [ + "memchr", +] + +[[package]] +name = "oboe" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e15e22bc67e047fe342a32ecba55f555e3be6166b04dd157cd0f803dfa9f48e1" dependencies = [ "jni", - "ndk", - "ndk-glue", + "ndk 0.4.0", + "ndk-glue 0.4.0", "num-derive", "num-traits", "oboe-sys", @@ -1639,9 +1784,9 @@ dependencies = [ [[package]] name = "oboe-sys" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b88e64835aa3f579c08d182526dc34e3907343d5b97e87b71a40ba5bca7aca9e" +checksum = "338142ae5ab0aaedc8275aa8f67f460e43ae0fca76a695a742d56da0a269eadc" dependencies = [ "cc", ] @@ -1657,9 +1802,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" +checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" [[package]] name = "opaque-debug" @@ -1668,10 +1813,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] -name = "parking_lot" -version = "0.11.1" +name = "openssl-probe" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" +checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", @@ -1680,23 +1831,26 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018" +checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" dependencies = [ + "backtrace", "cfg-if 1.0.0", "instant", "libc", + "petgraph", "redox_syscall", "smallvec", + "thread-id", "winapi", ] [[package]] name = "paste" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf547ad0c65e31259204bd90935776d1c693cec2f4ff7abb7a1bbbd40dfe58" +checksum = "0744126afe1a6dd7f394cb50a716dbe086cb06e255e53d8d0185d82828358fb5" [[package]] name = "pbkdf2" @@ -1721,28 +1875,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] -name = "pest" -version = "2.1.3" +name = "petgraph" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +checksum = "467d164a6de56270bd7c4d070df81d07beace25012d5103ced4e9ff08d6afdb7" dependencies = [ - "ucd-trie", + "fixedbitset", + "indexmap", ] [[package]] name = "pin-project" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7509cc106041c40a4518d2af7a61530e1eed0e6285296a3d8c5472806ccc4a4" +checksum = "576bc800220cc65dac09e99e97b08b358cfab6e17078de8dc5fee223bd2d0c08" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f" +checksum = "6e8fe8163d14ce7f0cdac2e040116f22eac817edabff0be91e8aff7e9accf389" dependencies = [ "proc-macro2", "quote", @@ -1751,9 +1906,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0e1f259c92177c30a4c9d177246edd0a3568b25756a977d0632cf8fa37e905" +checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443" [[package]] name = "pin-utils" @@ -1763,9 +1918,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.19" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" +checksum = "d1a3ea4f0dd7f1f3e512cf97bf100819aa547f36a6eccac8dbaae839eb92363e" [[package]] name = "portaudio-rs" @@ -1790,9 +1945,9 @@ dependencies = [ [[package]] name = "ppv-lite86" -version = "0.2.10" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +checksum = "ed0cfbc8191465bed66e1718596ee0b0b35d5ee1f41c5df2189d0fe8bde535ba" [[package]] name = "pretty-hex" @@ -1802,9 +1957,9 @@ checksum = "bc5c99d529f0d30937f6f4b8a86d988047327bb88d04d2c4afc356de74722131" [[package]] name = "priority-queue" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1340009a04e81f656a4e45e295f0b1191c81de424bf940c865e33577a8e223" +checksum = "00ba480ac08d3cfc40dea10fd466fd2c14dee3ea6fc7873bc4079eda2727caf0" dependencies = [ "autocfg", "indexmap", @@ -1819,6 +1974,16 @@ dependencies = [ "toml", ] +[[package]] +name = "proc-macro-crate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebace6889caf889b4d3f76becee12e90353f2b8c7d875534a71e5742f8f6f83" +dependencies = [ + "thiserror", + "toml", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -1857,52 +2022,62 @@ checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086" [[package]] name = "proc-macro2" -version = "1.0.27" +version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" +checksum = "fb37d2df5df740e582f28f8560cf425f52bb267d872fe58358eadb554909f07a" dependencies = [ "unicode-xid", ] [[package]] name = "protobuf" -version = "2.23.0" +version = "2.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45604fc7a88158e7d514d8e22e14ac746081e7a70d7690074dd0029ee37458d6" +checksum = "47c327e191621a2158159df97cdbc2e7074bb4e940275e35abf38eb3d2595754" [[package]] name = "protobuf-codegen" -version = "2.23.0" +version = "2.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb87f342b585958c1c086313dbc468dcac3edf5e90362111c26d7a58127ac095" +checksum = "3df8c98c08bd4d6653c2dbae00bd68c1d1d82a360265a5b0bbc73d48c63cb853" dependencies = [ "protobuf", ] [[package]] name = "protobuf-codegen-pure" -version = "2.23.0" +version = "2.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca6e0e2f898f7856a6328650abc9b2df71b7c1a5f39be0800d19051ad0214b2" +checksum = "394a73e2a819405364df8d30042c0f1174737a763e0170497ec9d36f8a2ea8f7" dependencies = [ "protobuf", "protobuf-codegen", ] [[package]] -name = "quote" -version = "1.0.9" +name = "quick-xml" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" dependencies = [ "proc-macro2", ] [[package]] name = "rand" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" +checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" dependencies = [ "libc", "rand_chacha", @@ -1912,9 +2087,9 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", @@ -1922,18 +2097,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ "getrandom", ] [[package]] name = "rand_distr" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9e8f32ad24fb80d07d2323a9a2ce8b30d68a62b8cb4df88119ff49a698f038" +checksum = "964d548f8e7d12e102ef183a0de7e98180c9f8729f555897a857b96e48122d2f" dependencies = [ "num-traits", "rand", @@ -1941,18 +2116,18 @@ dependencies = [ [[package]] name = "rand_hc" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" dependencies = [ "rand_core", ] [[package]] name = "redox_syscall" -version = "0.2.8" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" dependencies = [ "bitflags", ] @@ -2017,6 +2192,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "rustc-demangle" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2025,9 +2206,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc_version" -version = "0.3.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" dependencies = [ "semver", ] @@ -2046,10 +2227,22 @@ dependencies = [ ] [[package]] -name = "ryu" -version = "1.0.5" +name = "rustls-native-certs" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +checksum = "5a07b7c1885bd8ed3831c289b7870b13ef46fe0e856d288c30d9cc17d75a2092" +dependencies = [ + "openssl-probe", + "rustls", + "schannel", + "security-framework", +] + +[[package]] +name = "ryu" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9613b5a66ab9ba26415184cfc41156594925a9cf3a2057e57f31ff145f6568" [[package]] name = "same-file" @@ -2060,6 +2253,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75" +dependencies = [ + "lazy_static", + "winapi", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -2100,37 +2303,48 @@ dependencies = [ ] [[package]] -name = "semver" -version = "0.11.0" +name = "security-framework" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +checksum = "525bc1abfda2e1998d152c45cf13e696f76d0a4972310b22fac1658b05df7c87" dependencies = [ - "semver-parser", + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", ] [[package]] -name = "semver-parser" -version = "0.10.2" +name = "security-framework-sys" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" +checksum = "a9dd14d83160b528b7bfd66439110573efcfbe281b17fc2ca9f39f550d619c7e" dependencies = [ - "pest", + "core-foundation-sys", + "libc", ] +[[package]] +name = "semver" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568a8e6258aa33c13358f81fd834adb854c6f7c9468520910a9b1e8fac068012" + [[package]] name = "serde" -version = "1.0.126" +version = "1.0.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.126" +version = "1.0.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" dependencies = [ "proc-macro2", "quote", @@ -2139,9 +2353,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.64" +version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +checksum = "d0ffa0837f2dfa6fb90868c2b5468cad482e175f7dad97e7421951e663f2b527" dependencies = [ "itoa", "ryu", @@ -2150,9 +2364,9 @@ dependencies = [ [[package]] name = "sha-1" -version = "0.9.6" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c4cfa741c5832d0ef7fab46cabed29c2aae926db0b11bb2069edd8db5e64e16" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" dependencies = [ "block-buffer", "cfg-if 1.0.0", @@ -2184,43 +2398,30 @@ checksum = "7fdf1b9db47230893d76faad238fd6097fd6d6a9245cd7a4d90dbd639536bbd2" [[package]] name = "signal-hook-registry" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f1d0fef1604ba8f7a073c7e701f213e056707210e9020af4528e0101ce11a6" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" dependencies = [ "libc", ] -[[package]] -name = "simple_logger" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd57f17c093ead1d4a1499dc9acaafdd71240908d64775465543b8d9a9f1d198" -dependencies = [ - "atty", - "chrono", - "colored", - "log", - "winapi", -] - [[package]] name = "slab" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" +checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5" [[package]] name = "smallvec" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" +checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" [[package]] name = "socket2" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e3dfc207c526015c632472a77be09cf1b6e46866581aecae5cc38fb4235dea2" +checksum = "5dc90fe6c7be1a323296982db1836d1ea9e47b6839496dde9a541bc496df3516" dependencies = [ "libc", "winapi", @@ -2264,15 +2465,15 @@ dependencies = [ [[package]] name = "subtle" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.72" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82" +checksum = "8daf5dd0bb60cbd4137b1b587d2fc0ae729bc07cf01cd70b36a1ed5ade3b9d59" dependencies = [ "proc-macro2", "quote", @@ -2281,9 +2482,9 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.12.4" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b834f2d66f734cb897113e34aaff2f1ab4719ca946f9a7358dba8f8064148701" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" dependencies = [ "proc-macro2", "quote", @@ -2331,24 +2532,35 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.25" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa6f76457f59514c7eeb4e59d891395fab0b2fd1d40723ae737d64153392e9c6" +checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.25" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a36768c0fbf1bb15eca10defa29526bda730a2376c2ab4393ccfa16fb1a318d" +checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "thread-id" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fdfe0627923f7411a43ec9ec9c39c3a9b4151be313e0922042581fb6c9b717f" +dependencies = [ + "libc", + "redox_syscall", + "winapi", +] + [[package]] name = "time" version = "0.1.43" @@ -2361,9 +2573,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.2.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" +checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2" dependencies = [ "tinyvec_macros", ] @@ -2376,9 +2588,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.6.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd3076b5c8cc18138b8f8814895c11eb4de37114a5d127bafdc5e55798ceef37" +checksum = "70e992e41e0d2fb9f755b37446f20900f64446ef54874f40a60c78f021ac6144" dependencies = [ "autocfg", "bytes", @@ -2387,6 +2599,7 @@ dependencies = [ "mio", "num_cpus", "once_cell", + "parking_lot", "pin-project-lite", "signal-hook-registry", "tokio-macros", @@ -2395,9 +2608,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "1.2.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c49e3df43841dafb86046472506755d8501c5615673955f6aa17181125d13c37" +checksum = "c9efc1aba077437943f7515666aa2b882dfabfbfdf89c819ea75a8d6e9eaba5e" dependencies = [ "proc-macro2", "quote", @@ -2417,9 +2630,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8864d706fdb3cc0843a49647ac892720dac98a6eeb818b77190592cf4994066" +checksum = "50145484efff8818b5ccd256697f36863f587da82cf8b409c53adf1e840798e3" dependencies = [ "futures-core", "pin-project-lite", @@ -2445,9 +2658,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.6.7" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1caa0b0c8d94a049db56b5acf8cba99dc0623aab1b26d5b5f5e2d945846b3592" +checksum = "9e99e1983e5d376cd8eb4b66604d2e99e79f5bd988c3055891dcd8c9e2604cc0" dependencies = [ "bytes", "futures-core", @@ -2474,9 +2687,9 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" [[package]] name = "tracing" -version = "0.1.26" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09adeb8c97449311ccd28a427f96fb563e7fd31aabf994189879d9da2394b89d" +checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105" dependencies = [ "cfg-if 1.0.0", "pin-project-lite", @@ -2485,9 +2698,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.18" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ff14f98b1a4b289c6248a023c1c2fa1491062964e9fed67ab29c4e4da4a052" +checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4" dependencies = [ "lazy_static", ] @@ -2523,45 +2736,36 @@ dependencies = [ [[package]] name = "typenum" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" - -[[package]] -name = "ucd-trie" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" +checksum = "b63708a265f51345575b27fe43f9500ad611579e764c79edbc2037b1121959ec" [[package]] name = "unicode-bidi" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" -dependencies = [ - "matches", -] +checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" [[package]] name = "unicode-normalization" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07fbfce1c8a97d547e8b5334978438d9d6ec8c20e38f56d4a4374d181493eaef" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" [[package]] name = "unicode-width" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" [[package]] name = "unicode-xid" @@ -2654,9 +2858,9 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "wasm-bindgen" -version = "0.2.74" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54ee1d4ed486f78874278e63e4069fc1ab9f6a18ca492076ffb90c5eb2997fd" +checksum = "632f73e236b219150ea279196e54e610f5dbafa5d61786303d4da54f84e47fce" dependencies = [ "cfg-if 1.0.0", "wasm-bindgen-macro", @@ -2664,9 +2868,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.74" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b33f6a0694ccfea53d94db8b2ed1c3a8a4c86dd936b13b9f0a15ec4a451b900" +checksum = "a317bf8f9fba2476b4b2c85ef4c4af8ff39c3c7f0cdfeed4f82c34a880aa837b" dependencies = [ "bumpalo", "lazy_static", @@ -2679,9 +2883,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.74" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "088169ca61430fe1e58b8096c24975251700e7b1f6fd91cc9d59b04fb9b18bd4" +checksum = "d56146e7c495528bf6587663bea13a8eb588d39b36b679d83972e1a2dbbdacf9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2689,9 +2893,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.74" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2241542ff3d9f241f5e2cb6dd09b37efe786df8851c54957683a49f0987a97" +checksum = "7803e0eea25835f8abdc585cd3021b3deb11543c6fe226dcd30b228857c5c5ab" dependencies = [ "proc-macro2", "quote", @@ -2702,15 +2906,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.74" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff876b8f18eed75a66cf49b65e7f967cb354a7aa16003fb55dbfd25b44b4f" +checksum = "0237232789cf037d5480773fe568aac745bfe2afbc11a863e97901780a6b47cc" [[package]] name = "web-sys" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e828417b379f3df7111d3a2a9e5753706cae29c41f7c4029ee9fd77f3e09e582" +checksum = "38eb105f1c59d9eaa6b5cdc92b859d85b926e82cb2e0945cd0c9259faa6fe9fb" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index ced7d0f9..5a501ef5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librespot" -version = "0.2.0" +version = "0.3.1" authors = ["Librespot Org"] license = "MIT" description = "An open source client library for Spotify, with support for Spotify Connect" @@ -22,35 +22,35 @@ doc = false [dependencies.librespot-audio] path = "audio" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-connect] path = "connect" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-core] path = "core" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-discovery] path = "discovery" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-metadata] path = "metadata" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-playback] path = "playback" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-protocol] path = "protocol" -version = "0.2.0" +version = "0.3.1" [dependencies] base64 = "0.13" -env_logger = {version = "0.8", default-features = false, features = ["termcolor","humantime","atty"]} +env_logger = { version = "0.8", default-features = false, features = ["termcolor", "humantime", "atty"] } futures-util = { version = "0.3", default_features = false } getopts = "0.2.21" hex = "0.4" @@ -58,7 +58,7 @@ hyper = "0.14" log = "0.4" rpassword = "5.0" thiserror = "1.0" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "signal", "sync", "process"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "signal", "sync", "parking_lot", "process"] } url = "2.2" sha-1 = "0.9" @@ -72,7 +72,7 @@ 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"] default = ["rodio-backend"] diff --git a/README.md b/README.md index bcf73cac..5dbb5487 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ [![Gitter chat](https://badges.gitter.im/librespot-org/librespot.png)](https://gitter.im/librespot-org/spotify-connect-resources) [![Crates.io](https://img.shields.io/crates/v/librespot.svg)](https://crates.io/crates/librespot) -Current maintainer is [@awiouy](https://github.com/awiouy) folks. +Current maintainers are [listed on GitHub](https://github.com/orgs/librespot-org/people). # librespot *librespot* is an open source client library for Spotify. It enables applications to use Spotify's service to control and play music via various backends, and to act as a Spotify Connect receiver. It is an alternative to the official and [now deprecated](https://pyspotify.mopidy.com/en/latest/#libspotify-s-deprecation) closed-source `libspotify`. Additionally, it will provide extra features which are not available in the official library. -_Note: librespot only works with Spotify Premium. This will remain the case for the foreseeable future, as we are unlikely to work on implementing the features such as limited skips and adverts that would be required to make librespot compliant with free accounts._ +_Note: librespot only works with Spotify Premium. This will remain the case. We will not support any features to make librespot compatible with free accounts, such as limited skips and adverts._ ## Quick start We're available on [crates.io](https://crates.io/crates/librespot) as the _librespot_ package. Simply run `cargo install librespot` to install librespot on your system. Check the wiki for more info and possible [usage options](https://github.com/librespot-org/librespot/wiki/Options). -After installation, you can run librespot from the CLI using a command such as `librespot -n "Librespot Speaker" -b 160` to create a speaker called _Librespot Speaker_ serving 160kbps audio. +After installation, you can run librespot from the CLI using a command such as `librespot -n "Librespot Speaker" -b 160` to create a speaker called _Librespot Speaker_ serving 160 kbps audio. ## This fork As the origin by [plietar](https://github.com/plietar/) is no longer actively maintained, this organisation and repository have been set up so that the project may be maintained and upgraded in the future. @@ -20,7 +20,7 @@ As the origin by [plietar](https://github.com/plietar/) is no longer actively ma # Documentation Documentation is currently a work in progress, contributions are welcome! -There is some brief documentation on how the protocol works in the [docs](https://github.com/librespot-org/librespot/tree/master/docs) folder, +There is some brief documentation on how the protocol works in the [docs](https://github.com/librespot-org/librespot/tree/master/docs) folder. [COMPILING.md](https://github.com/librespot-org/librespot/blob/master/COMPILING.md) contains detailed instructions on setting up a development environment, and compiling librespot. More general usage and compilation information is available on the [wiki](https://github.com/librespot-org/librespot/wiki). [CONTRIBUTING.md](https://github.com/librespot-org/librespot/blob/master/CONTRIBUTING.md) also contains our contributing guidelines. @@ -30,37 +30,39 @@ If you wish to learn more about how librespot works overall, the best way is to # Issues & Discussions **We have recently started using Github discussions for general questions and feature requests, as they are a more natural medium for such cases, and allow for upvoting to prioritize feature development. Check them out [here](https://github.com/librespot-org/librespot/discussions). Bugs and issues with the underlying library should still be reported as issues.** -If you run into a bug when using librespot, please search the existing issues before opening a new one. Chances are, we've encountered it before, and have provided a resolution. If not, please open a new one, and where possible, include the backtrace librespot generates on crashing, along with anything we can use to reproduce the issue, eg. the Spotify URI of the song that caused the crash. +If you run into a bug when using librespot, please search the existing issues before opening a new one. Chances are, we've encountered it before, and have provided a resolution. If not, please open a new one, and where possible, include the backtrace librespot generates on crashing, along with anything we can use to reproduce the issue, e.g. the Spotify URI of the song that caused the crash. # Building -A quick walk through of the build process is outlined here, while a detailed compilation guide can be found [here](https://github.com/librespot-org/librespot/blob/master/COMPILING.md). +A quick walkthrough of the build process is outlined below, while a detailed compilation guide can be found [here](https://github.com/librespot-org/librespot/blob/master/COMPILING.md). ## Additional Dependencies We recently switched to using [Rodio](https://github.com/tomaka/rodio) for audio playback by default, hence for macOS and Windows, you should just be able to clone and build librespot (with the command below). For Linux, you will need to run the additional commands below, depending on your distro. -On Debian/Ubuntu, the following command will install these dependencies : +On Debian/Ubuntu, the following command will install these dependencies: ```shell sudo apt-get install build-essential libasound2-dev ``` -On Fedora systems, the following command will install these dependencies : +On Fedora systems, the following command will install these dependencies: ```shell sudo dnf install alsa-lib-devel make gcc ``` -librespot currently offers the following selection of [audio backends](https://github.com/librespot-org/librespot/wiki/Audio-Backends). +librespot currently offers the following selection of [audio backends](https://github.com/librespot-org/librespot/wiki/Audio-Backends): ``` Rodio (default) ALSA +GStreamer PortAudio PulseAudio JACK JACK over Rodio SDL Pipe +Subprocess ``` -Please check the corresponding [compiling entry](https://github.com/librespot-org/librespot/wiki/Compiling#general-dependencies) for backend specific dependencies. +Please check the corresponding [Compiling](https://github.com/librespot-org/librespot/wiki/Compiling#general-dependencies) entry on the wiki for backend specific dependencies. Once you've installed the dependencies and cloned this repository you can build *librespot* with the default backend using Cargo. ```shell @@ -84,14 +86,14 @@ The above is a minimal example. Here is a more fully fledged one: ```shell target/release/librespot -n "Librespot" -b 320 -c ./cache --enable-volume-normalisation --initial-volume 75 --device-type avr ``` -The above command will create a receiver named ```Librespot```, with bitrate set to 320kbps, initial volume at 75%, with volume normalisation enabled, and the device displayed in the app as an Audio/Video Receiver. A folder named ```cache``` will be created/used in the current directory, and be used to cache audio data and credentials. +The above command will create a receiver named ```Librespot```, with bitrate set to 320 kbps, initial volume at 75%, with volume normalisation enabled, and the device displayed in the app as an Audio/Video Receiver. A folder named ```cache``` will be created/used in the current directory, and be used to cache audio data and credentials. -A full list of runtime options are available [here](https://github.com/librespot-org/librespot/wiki/Options) +A full list of runtime options is available [here](https://github.com/librespot-org/librespot/wiki/Options). _Please Note: When using the cache feature, an authentication blob is stored for your account in the cache directory. For security purposes, we recommend that you set directory permissions on the cache directory to `700`._ ## Contact -Come and hang out on gitter if you need help or want to offer some. +Come and hang out on gitter if you need help or want to offer some: https://gitter.im/librespot-org/spotify-connect-resources ## Disclaimer @@ -114,3 +116,4 @@ functionality. - [librespot-java](https://github.com/devgianlu/librespot-java) - A Java port of librespot. - [ncspot](https://github.com/hrkfdn/ncspot) - Cross-platform ncurses Spotify client. - [ansible-role-librespot](https://github.com/xMordax/ansible-role-librespot/tree/master) - Ansible role that will build, install and configure Librespot. +- [Spot](https://github.com/xou816/spot) - Gtk/Rust native Spotify client for the GNOME desktop. diff --git a/audio/Cargo.toml b/audio/Cargo.toml index f4440592..c7cf0d7b 100644 --- a/audio/Cargo.toml +++ b/audio/Cargo.toml @@ -1,20 +1,25 @@ [package] name = "librespot-audio" -version = "0.2.0" +version = "0.3.1" authors = ["Paul Lietar "] description="The audio fetching and processing logic for librespot" -license="MIT" +license = "MIT" edition = "2018" [dependencies.librespot-core] path = "../core" -version = "0.2.0" +version = "0.3.1" [dependencies] aes-ctr = "0.6" byteorder = "1.4" bytes = "1.0" -log = "0.4" +futures-core = { version = "0.3", default-features = false } +futures-executor = "0.3" futures-util = { version = "0.3", default_features = false } +hyper = { version = "0.14", features = ["client"] } +log = "0.4" +parking_lot = { version = "0.11", features = ["deadlock_detection"] } tempfile = "3.1" -tokio = { version = "1", features = ["sync", "macros"] } +thiserror = "1.0" +tokio = { version = "1", features = ["macros", "parking_lot", "sync"] } diff --git a/audio/src/decrypt.rs b/audio/src/decrypt.rs index 616ef4f6..95dc7c08 100644 --- a/audio/src/decrypt.rs +++ b/audio/src/decrypt.rs @@ -1,8 +1,11 @@ use std::io; -use aes_ctr::cipher::generic_array::GenericArray; -use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher, SyncStreamCipherSeek}; -use aes_ctr::Aes128Ctr; +use aes_ctr::{ + cipher::{ + generic_array::GenericArray, NewStreamCipher, SyncStreamCipher, SyncStreamCipherSeek, + }, + Aes128Ctr, +}; use librespot_core::audio_key::AudioKey; @@ -18,8 +21,8 @@ pub struct AudioDecrypt { impl AudioDecrypt { pub fn new(key: AudioKey, reader: T) -> AudioDecrypt { let cipher = Aes128Ctr::new( - &GenericArray::from_slice(&key.0), - &GenericArray::from_slice(&AUDIO_AESIV), + GenericArray::from_slice(&key.0), + GenericArray::from_slice(&AUDIO_AESIV), ); AudioDecrypt { cipher, reader } } diff --git a/audio/src/fetch/mod.rs b/audio/src/fetch/mod.rs index 636194a8..f9e85d10 100644 --- a/audio/src/fetch/mod.rs +++ b/audio/src/fetch/mod.rs @@ -1,40 +1,77 @@ 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::{self, AtomicUsize}, + 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. /// 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 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 * 128; /// 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 +100,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 +109,16 @@ 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(1); pub enum AudioFile { Cached(fs::File), @@ -89,7 +126,16 @@ pub enum AudioFile { } #[derive(Debug)] -enum StreamLoaderCommand { +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 @@ -113,22 +159,28 @@ 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(atomic::Ordering::Relaxed); + self.range_available(Range::new(read_position, self.len() - read_position)) + } + None => true, + } } pub fn ping_time(&self) -> Duration { @@ -139,7 +191,8 @@ impl StreamLoaderController { 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 +202,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 +215,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,6 +242,8 @@ impl StreamLoaderController { } } } + + Ok(()) } pub fn fetch_next(&self, length: usize) { @@ -193,17 +252,20 @@ impl StreamLoaderController { start: shared.read_position.load(atomic::Ordering::Relaxed), 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); + pub fn fetch_next_blocking(&self, length: usize) -> AudioFileResult { + match self.stream_shared { + Some(ref shared) => { + let range = Range { + start: shared.read_position.load(atomic::Ordering::Relaxed), + length, + }; + self.fetch_blocking(range) + } + None => Ok(()), } } @@ -242,9 +304,9 @@ enum DownloadStrategy { } 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, @@ -259,7 +321,7 @@ impl AudioFile { 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 +330,35 @@ 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, + play_from_beginning, ); 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(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 +367,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 +382,80 @@ 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, + play_from_beginning: bool, + ) -> Result { + let download_size = 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 + }; - let size = BigEndian::read_u32(&data) as usize * 4; + let cdn_url = CdnUrl::new(file_id).resolve_audio(&session).await?; + + if let Ok(url) = cdn_url.try_get_url() { + trace!("Streaming from {}", url); + } + + let mut streamer = session + .spclient() + .stream_from_cdn(&cdn_url, 0, download_size)?; + let request_time = Instant::now(); + + // 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 response = streamer.next().await.ok_or(AudioFileError::NoData)??; + + let header_value = response + .headers() + .get(CONTENT_RANGE) + .ok_or(AudioFileError::Header)?; + let str_value = header_value.to_str()?; + let file_size_str = str_value.split('/').last().unwrap_or_default(); + let file_size = file_size_str.parse()?; + + let initial_request = StreamingRequest { + streamer, + initial_response: Some(response), + offset: 0, + length: download_size, + 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_strategy: Mutex::new(DownloadStrategy::Streaming()), number_of_open_requests: AtomicUsize::new(0), ping_time_ms: AtomicUsize::new(0), 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())?; + let read_file = write_file.reopen()?; - let read_file = write_file.reopen().unwrap(); - - // 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, @@ -402,7 +480,7 @@ impl Read for AudioFileStreaming { let length = min(output.len(), self.shared.file_size - offset); - let length_to_request = match *(self.shared.download_strategy.lock().unwrap()) { + let length_to_request = match *(self.shared.download_strategy.lock()) { DownloadStrategy::RandomAccess() => length, DownloadStrategy::Streaming() => { // Due to the read-ahead stuff, we potentially request more than the actual request demanded. @@ -414,10 +492,10 @@ impl Read for AudioFileStreaming { let length_to_request = length + max( (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() - * self.shared.stream_data_rate as f32) as usize, + * self.shared.bytes_per_second as f32) as usize, (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS * ping_time_seconds - * self.shared.stream_data_rate as f32) as usize, + * self.shared.bytes_per_second as f32) as usize, ); min(length_to_request, self.shared.file_size - offset) } @@ -426,34 +504,33 @@ impl Read for AudioFileStreaming { 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 @@ -461,19 +538,10 @@ impl Read for AudioFileStreaming { 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 diff --git a/audio/src/fetch/receive.rs b/audio/src/fetch/receive.rs index 5de90b79..e04c58d2 100644 --- a/audio/src/fetch/receive.rs +++ b/audio/src/fetch/receive.rs @@ -1,58 +1,27 @@ -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, 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::packet::PacketType; -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::{ - FAST_PREFETCH_THRESHOLD_FACTOR, MAXIMUM_ASSUMED_PING_TIME, MAX_PREFETCH_REQUESTS, - MINIMUM_DOWNLOAD_SIZE, PREFETCH_THRESHOLD_FACTOR, + AudioFileError, AudioFileResult, AudioFileShared, DownloadStrategy, 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(PacketType::StreamChunk, data); - - channel -} - struct PartialFileData { offset: usize, data: Bytes, @@ -66,13 +35,15 @@ 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 requested_offset = request.offset; + let requested_length = request.length; + + let mut data_offset = requested_offset; + let mut request_length = requested_length; + + // TODO : check Content-Length and Content-Range headers let old_number_of_request = shared .number_of_open_requests @@ -80,31 +51,49 @@ 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 => break Ok(()), + }, + }; + + let code = response.status(); + let body = response.into_body(); + + if code != StatusCode::PARTIAL_CONTENT { + debug!("Streamer expected partial content but got: {}", code); + break Err(AudioFileError::StatusCode(code).into()); + } + + let data = match hyper::body::to_bytes(body).await { + Ok(bytes) => bytes, + Err(e) => break Err(e.into()), }; if measure_ping_time { - let mut duration = Instant::now() - request_sent_time; + let mut duration = Instant::now() - request.request_time; if duration > MAXIMUM_ASSUMED_PING_TIME { duration = MAXIMUM_ASSUMED_PING_TIME; } - let _ = file_data_tx.send(ReceivedData::ResponseTime(duration)); + file_data_tx.send(ReceivedData::ResponseTime(duration))?; measure_ping_time = false; } + let data_size = data.len(); - let _ = file_data_tx.send(ReceivedData::Data(PartialFileData { + + 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 + "Data receiver for range {} (+{}) received more data from server than requested ({} instead of {}).", + requested_offset, requested_length, data_size, request_length ); request_length = 0; } else { @@ -116,28 +105,38 @@ async fn receive_data( } }; - 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(); + if request_length > 0 { + { + let missing_range = Range::new(data_offset, request_length); + 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 - ); + match result { + Ok(()) => { + if request_length > 0 { + warn!( + "Streamer for range {} (+{}) received less data from server than requested.", + requested_offset, requested_length + ); + } + Ok(()) + } + Err(e) => { + error!( + "Error from streamer for range {} (+{}): {:?}", + requested_offset, requested_length, e + ); + Err(e) + } } } @@ -160,67 +159,63 @@ enum ControlFlow { impl AudioFileFetch { fn get_download_strategy(&mut self) -> DownloadStrategy { - *(self.shared.download_strategy.lock().unwrap()) + *(self.shared.download_strategy.lock()) } - 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; @@ -229,7 +224,7 @@ 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); } @@ -247,7 +242,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() { @@ -255,19 +250,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) => { - 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 +275,129 @@ 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(); + *(self.shared.download_strategy.lock()) = DownloadStrategy::RandomAccess(); } StreamLoaderCommand::StreamMode() => { - *(self.shared.download_strategy.lock().unwrap()) = DownloadStrategy::Streaming(); + *(self.shared.download_strategy.lock()) = DownloadStrategy::Streaming(); } - StreamLoaderCommand::Close() => return ControlFlow::Break, + 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,13 +409,23 @@ 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() => { - if data.map_or(true, |data| fetch.handle_file_data(data) == ControlFlow::Break) { - break; + data = file_data_rx.recv() => { + match data { + Some(data) => { + if fetch.handle_file_data(data)? == ControlFlow::Break { + break; + } + } + None => break, } } } @@ -410,7 +437,8 @@ pub(super) async fn audio_file_fetch( 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 +453,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 +462,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..5685486d 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,7 +7,7 @@ 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, diff --git a/audio/src/range_set.rs b/audio/src/range_set.rs index f74058a3..005a4cda 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, ")") } diff --git a/connect/Cargo.toml b/connect/Cargo.toml index 89d185ab..ab425a66 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librespot-connect" -version = "0.2.0" +version = "0.3.1" authors = ["Paul Lietar "] description = "The discovery and Spotify Connect logic for librespot" license = "MIT" @@ -15,24 +15,25 @@ protobuf = "2.14.0" rand = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tokio = { version = "1.0", features = ["macros", "sync"] } +thiserror = "1.0" +tokio = { version = "1.0", features = ["macros", "parking_lot", "sync"] } tokio-stream = "0.1.1" [dependencies.librespot-core] path = "../core" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-playback] path = "../playback" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-protocol] path = "../protocol" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-discovery] path = "../discovery" -version = "0.2.0" +version = "0.3.1" [features] with-dns-sd = ["librespot-discovery/with-dns-sd"] diff --git a/connect/src/context.rs b/connect/src/context.rs index 63a2aebb..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 { @@ -46,6 +51,7 @@ pub struct TrackContext { // pub metadata: MetadataContext, } +#[allow(dead_code)] #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct ArtistContext { @@ -54,6 +60,7 @@ pub struct ArtistContext { image_uri: String, } +#[allow(dead_code)] #[derive(Deserialize, Debug)] pub struct MetadataContext { album_title: String, @@ -70,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 index 8ce3f4f0..8f4f9b34 100644 --- a/connect/src/discovery.rs +++ b/connect/src/discovery.rs @@ -1,10 +1,11 @@ -use std::io; -use std::pin::Pin; -use std::task::{Context, Poll}; +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; use futures_util::Stream; -use librespot_core::authentication::Credentials; -use librespot_core::config::ConnectConfig; +use librespot_core::{authentication::Credentials, config::ConnectConfig}; pub struct DiscoveryStream(librespot_discovery::Discovery); diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 57dc4cdd..144b9f24 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,27 +1,67 @@ -use std::future::Future; -use std::pin::Pin; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + convert::TryFrom, + future::Future, + pin::Pin, + 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, TryFutureExt, +}; -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::{ + context::StationContext, + core::{ + config::ConnectConfig, // TODO: move to connect? + 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 { @@ -56,15 +96,18 @@ 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, } @@ -107,7 +150,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); @@ -225,25 +268,67 @@ impl Spirc { session: Session, player: Player, mixer: Box, - ) -> (Spirc, impl Future) { + ) -> Result<(Spirc, impl Future), Error> { debug!("new Spirc[{}]", session.session_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 canonical_username = &session.username(); + debug!("canonical_username: {}", canonical_username); + let uri = format!("hm://remote/user/{}/", url_encode(canonical_username)); - let subscription = Box::pin( + let remote_update = Box::pin( session .mercury() .subscribe(uri.clone()) - .map(Result::unwrap) + .inspect_err(|x| error!("remote update error: {}", x)) + .and_then(|x| async move { Ok(x) }) + .map(Result::unwrap) // guaranteed to be safe by `and_then` above .map(UnboundedReceiverStream::new) .flatten_stream() - .map(|response| -> Frame { - let data = response.payload.first().unwrap(); - Frame::parse_from_bytes(data).unwrap() + .map(|response| -> Result { + let data = response.payload.first().ok_or(SpircError::NoData)?; + Ok(Frame::parse_from_bytes(data)?) + }), + ); + + 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)?) }), ); @@ -274,7 +359,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), @@ -296,37 +384,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 +424,57 @@ 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(update) => if let Err(e) = self.handle_remote_update(update) { + error!("could not dispatch remote update: {}", e); + } + Err(e) => error!("could not parse remote update: {}", e), + } None => { error!("subscription terminated"); 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."); @@ -417,79 +544,80 @@ 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, true) } 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, true) } 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, true) } 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, true) } 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, true) } 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, true) } 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, true) } 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.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 @@ -498,8 +626,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, false) + } PlayerEvent::Playing { position_ms, .. } => { + trace!("==> kPlayStatusPlay"); let new_nominal_start_time = self.now_ms() - position_ms as i64; match self.play_status { SpircPlayStatus::Playing { @@ -509,27 +642,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, true) + } 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, true) } - _ => (), - }; - trace!("==> kPlayStatusPlay"); + _ => Ok(()), + } } PlayerEvent::Paused { position_ms: new_position_ms, .. } => { + trace!("==> kPlayStatusPause"); match self.play_status { SpircPlayStatus::Paused { ref mut position_ms, @@ -538,42 +673,96 @@ 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, true) + } 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, true) } - _ => (), + _ => 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(()), + _ => { + warn!("The player has stopped unexpectedly."); + self.state.set_status(PlayStatus::kPlayStatusStop); + self.play_status = SpircPlayStatus::Stopped; + self.notify(None, true) + } } - }, - 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 + ); + } 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() { + fn handle_remote_update(&mut self, update: Frame) -> Result<(), Error> { + let state_string = match update.get_state().get_status() { PlayStatus::kPlayStatusLoading => "kPlayStatusLoading", PlayStatus::kPlayStatusPause => "kPlayStatusPause", PlayStatus::kPlayStatusStop => "kPlayStatusStop", @@ -582,24 +771,24 @@ impl SpircTask { debug!( "{:?} {:?} {} {} {} {}", - frame.get_typ(), - frame.get_device_state().get_name(), - frame.get_ident(), - frame.get_seq_nr(), - frame.get_state_update_id(), + update.get_typ(), + update.get_device_state().get_name(), + update.get_ident(), + update.get_seq_nr(), + update.get_state_update_id(), state_string, ); - if frame.get_ident() == self.ident - || (!frame.get_recipient().is_empty() && !frame.get_recipient().contains(&self.ident)) + 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); - } + match update.get_typ() { + MessageType::kMessageTypeHello => self.notify(Some(ident), true), MessageType::kMessageTypeLoad => { if !self.device.get_is_active() { @@ -608,12 +797,12 @@ 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); @@ -621,51 +810,51 @@ impl SpircTask { self.play_status = SpircPlayStatus::Stopped; } - self.notify(None, true); + self.notify(None, true) } MessageType::kMessageTypePlay => { self.handle_play(); - self.notify(None, true); + self.notify(None, true) } MessageType::kMessageTypePlayPause => { self.handle_play_pause(); - self.notify(None, true); + self.notify(None, true) } MessageType::kMessageTypePause => { self.handle_pause(); - self.notify(None, true); + self.notify(None, true) } MessageType::kMessageTypeNext => { self.handle_next(); - self.notify(None, true); + self.notify(None, true) } MessageType::kMessageTypePrev => { self.handle_prev(); - self.notify(None, true); + self.notify(None, true) } MessageType::kMessageTypeVolumeUp => { self.handle_volume_up(); - self.notify(None, true); + self.notify(None, true) } MessageType::kMessageTypeVolumeDown => { self.handle_volume_down(); - self.notify(None, true); + self.notify(None, true) } 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, true) } 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(); { @@ -681,17 +870,17 @@ impl SpircTask { let context = self.state.get_context_uri(); debug!("{:?}", context); } - self.notify(None, true); + self.notify(None, true) } MessageType::kMessageTypeSeek => { - self.handle_seek(frame.get_position()); - self.notify(None, true); + self.handle_seek(update.get_position()); + self.notify(None, true) } MessageType::kMessageTypeReplace => { - self.update_tracks(&frame); - self.notify(None, true); + self.update_tracks(&update); + self.notify(None, true)?; if let SpircPlayStatus::Playing { preloading_of_next_track_triggered, @@ -709,27 +898,29 @@ impl SpircTask { } } } + Ok(()) } 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, true) } 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; } + Ok(()) } - _ => (), + _ => Ok(()), } } @@ -739,11 +930,6 @@ impl SpircTask { 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); @@ -756,8 +942,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) { @@ -887,8 +1078,8 @@ impl SpircTask { let tracks_len = self.state.get_track().len() as u32; debug!( "At track {:?} of {:?} <{:?}> update [{}]", - new_index, - self.state.get_track().len(), + new_index + 1, + tracks_len, self.state.get_context_uri(), tracks_len - new_index < CONTEXT_FETCH_THRESHOLD ); @@ -902,16 +1093,20 @@ impl SpircTask { self.context_fut = self.resolve_station(&context_uri); self.update_tracks_from_context(); } - if self.config.autoplay && new_index == tracks_len - 1 { - // Extend the playlist - // Note: This doesn't seem to reflect in the UI - // the additional tracks in the frame don't show up as with station view - debug!("Extending playlist <{}>", context_uri); - self.update_tracks_from_context(); - } if new_index >= tracks_len { - new_index = 0; // Loop around back to start - continue_playing = self.state.get_repeat(); + if self.config.autoplay { + // Extend the playlist + debug!("Extending playlist <{}>", context_uri); + self.update_tracks_from_context(); + self.player.set_auto_normalise_as_album(false); + } else { + new_index = 0; + continue_playing = self.state.get_repeat(); + debug!( + "Looping around back to start, repeat is {}", + continue_playing + ); + } } if tracks_len > 0 { @@ -975,9 +1170,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, true) } fn position(&mut self) -> u32 { @@ -992,48 +1187,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) } @@ -1051,7 +1238,7 @@ impl SpircTask { if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) { track_vec.drain(0..head); } - track_vec.extend_from_slice(&new_tracks); + track_vec.extend_from_slice(new_tracks); self.state .set_track(protobuf::RepeatedField::from_vec(track_vec)); @@ -1069,11 +1256,11 @@ 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:") { @@ -1084,6 +1271,9 @@ impl SpircTask { self.autoplay_fut = self.resolve_autoplay_uri(&context_uri); } + self.player + .set_auto_normalise_as_album(context_uri.starts_with("spotify:album:")); + self.state.set_playing_track_index(index); self.state.set_track(tracks.iter().cloned().collect()); self.state.set_context_uri(context_uri); @@ -1099,15 +1289,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, @@ -1133,6 +1314,14 @@ impl SpircTask { fn get_track_id_to_play_from_playlist(&self, index: u32) -> Option<(SpotifyId, u32)> { let tracks_len = self.state.get_track().len(); + // Guard against tracks_len being zero to prevent + // 'index out of bounds: the len is 0 but the index is 0' + // https://github.com/librespot-org/librespot/issues/226#issuecomment-971642037 + if tracks_len == 0 { + warn!("No playable track found in state: {:?}", self.state); + return None; + } + let mut new_playlist_index = index as usize; if new_playlist_index >= tracks_len { @@ -1146,11 +1335,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 @@ -1166,7 +1352,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 { @@ -1201,13 +1387,17 @@ impl SpircTask { } } - 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) { + fn notify( + &mut self, + recipient: Option<&str>, + suppress_loading_status: bool, + ) -> Result<(), Error> { if suppress_loading_status && (self.state.get_status() == PlayStatus::kPlayStatusLoading) { - return; + return Ok(()); }; let status_string = match self.state.get_status() { PlayStatus::kPlayStatusLoading => "kPlayStatusLoading", @@ -1218,9 +1408,9 @@ impl SpircTask { trace!("Sending status to server: [{}]", status_string); let mut cs = CommandSender::new(self, MessageType::kMessageTypeNotify); if let Some(s) = recipient { - cs = cs.recipient(&s); + cs = cs.recipient(s); } - cs.send(); + cs.send() } fn set_volume(&mut self, volume: u16) { @@ -1268,11 +1458,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/contrib/Dockerfile b/contrib/Dockerfile index 74b83d31..aa29183c 100644 --- a/contrib/Dockerfile +++ b/contrib/Dockerfile @@ -9,8 +9,10 @@ # # If only one architecture is desired, cargo can be invoked directly with the appropriate options : # $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --no-default-features --features "alsa-backend" -# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features "alsa-backend" -# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features "alsa-backend" +# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features alsa-backend +# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features alsa-backend +# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target aarch64-unknown-linux-gnu --no-default-features --features alsa-backend + # $ docker run -v /tmp/librespot-build:/build librespot-cross contrib/docker-build-pi-armv6hf.sh FROM debian:stretch diff --git a/contrib/librespot.service b/contrib/librespot.service index 76037c8c..2c92a149 100644 --- a/contrib/librespot.service +++ b/contrib/librespot.service @@ -2,12 +2,12 @@ Description=Librespot (an open source Spotify client) Documentation=https://github.com/librespot-org/librespot Documentation=https://github.com/librespot-org/librespot/wiki/Options -Requires=network-online.target -After=network-online.target +Wants=network.target sound.target +After=network.target sound.target [Service] -User=nobody -Group=audio +DynamicUser=yes +SupplementaryGroups=audio Restart=always RestartSec=10 ExecStart=/usr/bin/librespot --name "%p@%H" diff --git a/contrib/librespot.user.service b/contrib/librespot.user.service index a676dde0..36f7f8c9 100644 --- a/contrib/librespot.user.service +++ b/contrib/librespot.user.service @@ -2,6 +2,8 @@ Description=Librespot (an open source Spotify client) Documentation=https://github.com/librespot-org/librespot Documentation=https://github.com/librespot-org/librespot/wiki/Options +Wants=network.target sound.target +After=network.target sound.target [Service] Restart=always diff --git a/core/Cargo.toml b/core/Cargo.toml index 3c239034..271e5896 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librespot-core" -version = "0.2.0" +version = "0.3.1" authors = ["Paul Lietar "] build = "build.rs" description = "The core functionality provided by librespot" @@ -10,21 +10,24 @@ edition = "2018" [dependencies.librespot-protocol] path = "../protocol" -version = "0.2.0" +version = "0.3.1" [dependencies] aes = "0.6" base64 = "0.13" byteorder = "1.4" -bytes = "1.0" +bytes = "1" +chrono = "0.4" +dns-sd = { version = "0.1.3", 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"] } +futures-util = { version = "0.3", default-features = false, features = ["alloc", "bilock", "sink", "unstable"] } hmac = "0.11" httparse = "1.3" 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.1", default-features = false, features = ["rustls"] } +hyper-rustls = { version = "0.22", default-features = false, features = ["native-tokio"] } log = "0.4" num = "0.4" num-bigint = { version = "0.4", features = ["rand"] } @@ -32,16 +35,20 @@ num-derive = "0.3" num-integer = "0.1" num-traits = "0.2" once_cell = "1.5.2" +parking_lot = { version = "0.11", features = ["deadlock_detection"] } pbkdf2 = { version = "0.8", default-features = false, features = ["hmac"] } priority-queue = "1.1" protobuf = "2.14.0" +quick-xml = { version = "0.22", features = ["serialize"] } rand = "0.8" +rustls = "0.19" +rustls-native-certs = "0.5" 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.5", features = ["io-util", "macros", "net", "rt", "time", "sync"] } +thiserror = "1.0" +tokio = { version = "1.5", features = ["io-util", "macros", "net", "parking_lot", "rt", "sync", "time"] } tokio-stream = "0.1.1" tokio-tungstenite = { version = "0.14", default-features = false, features = ["rustls-tls"] } tokio-util = { version = "0.6", features = ["codec"] } @@ -54,4 +61,7 @@ vergen = "3.0.4" [dev-dependencies] env_logger = "0.8" -tokio = {version = "1.0", features = ["macros"] } +tokio = { version = "1.0", features = ["macros", "parking_lot"] } + +[features] +with-dns-sd = ["dns-sd"] diff --git a/core/src/apresolve.rs b/core/src/apresolve.rs index 623c7cb3..69a8e15c 100644 --- a/core/src/apresolve.rs +++ b/core/src/apresolve.rs @@ -1,19 +1,21 @@ -use hyper::{Body, Request}; -use serde::Deserialize; -use std::error::Error; use std::sync::atomic::{AtomicUsize, Ordering}; +use hyper::{Body, Method, Request}; +use serde::Deserialize; + +use crate::Error; + pub type SocketAddress = (String, u16); #[derive(Default)] -struct AccessPoints { +pub struct AccessPoints { accesspoint: Vec, dealer: Vec, spclient: Vec, } #[derive(Deserialize)] -struct ApResolveData { +pub struct ApResolveData { accesspoint: Vec, dealer: Vec, spclient: Vec, @@ -42,7 +44,7 @@ component! { 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). - fn port_config(&self) -> Option { + 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 { @@ -54,9 +56,7 @@ impl ApResolver { data.into_iter() .filter_map(|ap| { let mut split = ap.rsplitn(2, ':'); - let port = split - .next() - .expect("rsplitn should not return empty iterator"); + let port = split.next()?; let host = split.next()?.to_owned(); let port: u16 = port.parse().ok()?; if let Some(p) = self.port_config() { @@ -69,12 +69,11 @@ impl ApResolver { .collect() } - async fn try_apresolve(&self) -> Result> { + pub async fn try_apresolve(&self) -> Result { let req = Request::builder() - .method("GET") + .method(Method::GET) .uri("http://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient") - .body(Body::empty()) - .unwrap(); + .body(Body::empty())?; let body = self.session().http_client().request_body(req).await?; let data: ApResolveData = serde_json::from_slice(body.as_ref())?; diff --git a/core/src/audio_key.rs b/core/src/audio_key.rs index f42c6502..74be4258 100644 --- a/core/src/audio_key.rs +++ b/core/src/audio_key.rs @@ -1,53 +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::packet::PacketType; -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: PacketType, 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 { - PacketType::AesKey => { - let mut key = [0u8; 16]; - key.copy_from_slice(data.as_ref()); - let _ = sender.send(Ok(AudioKey(key))); - } - PacketType::AesKeyError => { - 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| { @@ -56,16 +88,16 @@ 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_all(&file.0).unwrap(); - data.write_all(&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(PacketType::RequestKey, data) } diff --git a/core/src/authentication.rs b/core/src/authentication.rs index db787bbe..ad7cf331 100644 --- a/core/src/authentication.rs +++ b/core/src/authentication.rs @@ -7,8 +7,21 @@ 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), +} + +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)] @@ -42,7 +55,11 @@ impl Credentials { } } - pub fn with_blob(username: String, encrypted_blob: &str, device_id: &str) -> Credentials { + pub fn with_blob( + username: impl Into, + encrypted_blob: impl AsRef<[u8]>, + device_id: impl AsRef<[u8]>, + ) -> Result { fn read_u8(stream: &mut R) -> io::Result { let mut data = [0u8]; stream.read_exact(&mut data)?; @@ -67,7 +84,9 @@ impl Credentials { Ok(data) } - let secret = Sha1::digest(device_id.as_bytes()); + let username = username.into(); + + let secret = Sha1::digest(device_id.as_ref()); let key = { let mut key = [0u8; 24]; @@ -85,12 +104,12 @@ impl Credentials { use aes::cipher::generic_array::GenericArray; use aes::cipher::{BlockCipher, NewBlockCipher}; - 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(); + assert_eq!(data.len() % block_size, 0); - // replace to chunks_exact_mut with MSRV bump to 1.31 - for chunk in data.chunks_mut(block_size) { + for chunk in data.chunks_exact_mut(block_size) { cipher.decrypt_block(GenericArray::from_mut_slice(chunk)); } @@ -102,20 +121,21 @@ impl Credentials { data }; - let mut cursor = io::Cursor::new(&blob); - 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(); + let mut cursor = io::Cursor::new(blob.as_slice()); + 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 612b7c39..9484bb16 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,33 +256,39 @@ pub struct Cache { size_limiter: Option>, } -pub struct RemoveFileError(()); - impl Cache { pub fn new>( - system_location: Option

, - audio_location: Option

, + credentials_path: Option

, + volume_path: Option

, + audio_path: Option

, size_limit: Option, - ) -> io::Result { - if let Some(location) = &system_location { + ) -> Result { + let mut size_limiter = None; + + if let Some(location) = &credentials_path { fs::create_dir_all(location)?; } - let mut size_limiter = None; + let credentials_location = credentials_path + .as_ref() + .map(|p| p.as_ref().join("credentials.json")); - if let Some(location) = &audio_location { + if let Some(location) = &volume_path { fs::create_dir_all(location)?; + } + + let volume_location = volume_path.as_ref().map(|p| p.as_ref().join("volume")); + + if let Some(location) = &audio_path { + 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)); } } - let audio_location = audio_location.map(|p| p.as_ref().to_owned()); - let volume_location = system_location.as_ref().map(|p| p.as_ref().join("volume")); - let credentials_location = system_location - .as_ref() - .map(|p| p.as_ref().join("credentials.json")); + let audio_location = audio_path.map(|p| p.as_ref().to_owned()); let cache = Cache { credentials_location, @@ -276,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() { @@ -288,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 @@ -312,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 @@ -355,12 +381,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 @@ -368,38 +396,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<(), Error> { + 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(()); + } } } + 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..befdefd6 --- /dev/null +++ b/core/src/cdn_url.rs @@ -0,0 +1,166 @@ +use std::{ + convert::{TryFrom, TryInto}, + ops::{Deref, DerefMut}, +}; + +use chrono::Local; +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 = Local::now(); + let url = self.urls.iter().find(|url| match url.1 { + Some(expiry) => now < expiry.as_utc(), + 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(expiry.try_into()?), + )) + } 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 31c01a40..607189a0 100644 --- a/core/src/channel.rs +++ b/core/src/channel.rs @@ -1,18 +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::packet::PacketType; -use crate::util::SeqGenerator; +use crate::{packet::PacketType, util::SeqGenerator, Error}; component! { ChannelManager : ChannelManagerInner { @@ -27,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, @@ -70,7 +84,7 @@ impl ChannelManager { (seq, channel) } - pub(crate) fn dispatch(&self, cmd: PacketType, 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()); @@ -94,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 as u8, data)); + entry + .get() + .send((cmd as u8, data)) + .map_err(|_| ChannelError)?; } - }); + + Ok(()) + }) } pub fn get_download_rate_estimate(&self) -> usize { @@ -142,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))?; 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 0e3eaf4a..f04326ae 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,23 +1,23 @@ -use std::fmt; -use std::str::FromStr; +use std::{fmt, path::PathBuf, str::FromStr}; + use url::Url; #[derive(Clone, Debug)] pub struct SessionConfig { - pub user_agent: 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().to_hyphenated().to_string(); SessionConfig { - user_agent: crate::version::VERSION_STRING.to_string(), device_id, proxy: None, ap_port: None, + tmp_dir: std::env::temp_dir(), } } } @@ -125,3 +125,15 @@ pub struct ConnectConfig { 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 299220f6..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, @@ -88,7 +96,9 @@ impl Decoder for ApCodec { let mut payload = buf.split_to(size + MAC_SIZE); self.decode_cipher - .decrypt(&mut payload.get_mut(..size).unwrap()); + .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 82ec7672..42d64df2 100644 --- a/core/src/connection/handshake.rs +++ b/core/src/connection/handshake.rs @@ -1,16 +1,28 @@ +use std::{env::consts::ARCH, io}; + use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; use hmac::{Hmac, Mac, NewMac}; use protobuf::{self, Message}; use rand::{thread_rng, RngCore}; use sha1::Sha1; -use std::io; +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, +}; + +#[derive(Debug, Error)] +pub enum HandshakeError { + #[error("invalid key length")] + InvalidLength, +} pub async fn handshake( mut connection: T, @@ -27,7 +39,7 @@ pub async fn handshake( .to_owned(); 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 +54,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 { + "arm64" => Platform::PLATFORM_IPHONE_ARM64, + _ => Platform::PLATFORM_IPHONE_ARM, + }, + "linux" => match ARCH { + "arm" | "arm64" => 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" => 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 +120,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 +141,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 +156,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 +172,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 472109e6..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::io; use futures_util::{SinkExt, StreamExt}; use num_traits::FromPrimitive; -use protobuf::{self, Message, ProtobufError}; +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::packet::PacketType; +use crate::{authentication::Credentials, packet::PacketType, version, Error}; + use crate::protocol::keyexchange::{APLoginFailed, ErrorCode}; -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), + } } } @@ -68,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() @@ -81,29 +108,30 @@ 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 = PacketType::Login; - let data = packet.write_to_bytes().unwrap(); + let data = packet.write_to_bytes()?; transport.send((cmd as u8, data)).await?; - let (cmd, data) = transport.next().await.expect("EOF")?; + let (cmd, data) = transport + .next() + .await + .ok_or(AuthenticationError::Transport)??; let packet_type = FromPrimitive::from_u8(cmd); - match packet_type { + let result = match packet_type { Some(PacketType::APWelcome) => { let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?; @@ -120,8 +148,13 @@ pub async fn authenticate( 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..fe052299 --- /dev/null +++ b/core/src/date.rs @@ -0,0 +1,79 @@ +use std::{convert::TryFrom, fmt::Debug, ops::Deref}; + +use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; +use thiserror::Error; + +use crate::Error; + +use librespot_protocol as protocol; +use protocol::metadata::Date as DateMessage; + +#[derive(Debug, Error)] +pub enum DateError { + #[error("item has invalid timestamp {0}")] + Timestamp(i64), +} + +impl From for Error { + fn from(err: DateError) -> Self { + Error::invalid_argument(err) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Date(pub DateTime); + +impl Deref for Date { + type Target = DateTime; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Date { + pub fn as_timestamp(&self) -> i64 { + self.0.timestamp() + } + + pub fn from_timestamp(timestamp: i64) -> Result { + if let Some(date_time) = NaiveDateTime::from_timestamp_opt(timestamp, 0) { + Ok(Self::from_utc(date_time)) + } else { + Err(DateError::Timestamp(timestamp).into()) + } + } + + pub fn as_utc(&self) -> DateTime { + self.0 + } + + pub fn from_utc(date_time: NaiveDateTime) -> Self { + Self(DateTime::::from_utc(date_time, Utc)) + } +} + +impl From<&DateMessage> for Date { + fn from(date: &DateMessage) -> Self { + let naive_date = NaiveDate::from_ymd( + date.get_year() as i32, + date.get_month() as u32, + date.get_day() as u32, + ); + let naive_time = NaiveTime::from_hms(date.get_hour() as u32, date.get_minute() as u32, 0); + let naive_datetime = NaiveDateTime::new(naive_date, naive_time); + Self(DateTime::::from_utc(naive_datetime, Utc)) + } +} + +impl From> for Date { + fn from(date: DateTime) -> Self { + Self(date) + } +} + +impl TryFrom for Date { + type Error = crate::Error; + fn try_from(timestamp: i64) -> Result { + Self::from_timestamp(timestamp) + } +} diff --git a/core/src/dealer/maps.rs b/core/src/dealer/maps.rs index 38916e40..4f719de7 100644 --- a/core/src/dealer/maps.rs +++ b/core/src/dealer/maps.rs @@ -1,7 +1,20 @@ use std::collections::HashMap; -#[derive(Debug)] -pub struct AlreadyHandledError(()); +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), @@ -19,9 +32,9 @@ impl HandlerMap { &mut self, mut path: impl Iterator, handler: T, - ) -> Result<(), AlreadyHandledError> { + ) -> Result<(), Error> { match self { - Self::Leaf(_) => Err(AlreadyHandledError(())), + Self::Leaf(_) => Err(HandlerMapError::AlreadyHandled.into()), Self::Branch(children) => { if let Some(component) = path.next() { let node = children.entry(component.to_owned()).or_default(); @@ -30,7 +43,7 @@ impl HandlerMap { *self = Self::Leaf(handler); Ok(()) } else { - Err(AlreadyHandledError(())) + Err(HandlerMapError::AlreadyHandled.into()) } } } diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index bca1ec20..c1a9c94d 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -1,29 +1,41 @@ mod maps; pub mod protocol; -use std::iter; -use std::pin::Pin; -use std::sync::atomic::AtomicBool; -use std::sync::{atomic, Arc, Mutex}; -use std::task::Poll; -use std::time::Duration; +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; -use futures_util::{SinkExt, StreamExt}; +use futures_util::{future::join_all, SinkExt, StreamExt}; +use parking_lot::Mutex; use thiserror::Error; -use tokio::select; -use tokio::sync::mpsc::{self, UnboundedReceiver}; -use tokio::sync::Semaphore; -use tokio::task::JoinHandle; +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; -use crate::util::{keep_flushing, CancelOnDrop, TimeoutOnDrop}; + +use crate::{ + socket, + util::{keep_flushing, CancelOnDrop, TimeoutOnDrop}, + Error, +}; type WsMessage = tungstenite::Message; type WsError = tungstenite::Error; @@ -164,24 +176,38 @@ fn split_uri(s: &str) -> Option> { pub enum AddHandlerError { #[error("There is already a handler for the given uri")] AlreadyHandled, - #[error("The specified uri is invalid")] - InvalidUri, + #[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, + 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<(), AddHandlerError> { - let split = split_uri(uri).ok_or(AddHandlerError::InvalidUri)?; +) -> Result<(), Error> { + let split = split_uri(uri).ok_or_else(|| AddHandlerError::InvalidUri(uri.to_string()))?; map.insert(split, Box::new(handler)) - .map_err(|_| AddHandlerError::AlreadyHandled) } fn remove_handler(map: &mut HandlerMap, uri: &str) -> Option { @@ -191,11 +217,11 @@ fn remove_handler(map: &mut HandlerMap, uri: &str) -> Option { fn subscribe( map: &mut SubscriberMap, uris: &[&str], -) -> Result { +) -> Result { let (tx, rx) = mpsc::unbounded_channel(); for &uri in uris { - let split = split_uri(uri).ok_or(SubscriptionError::InvalidUri)?; + let split = split_uri(uri).ok_or_else(|| SubscriptionError::InvalidUri(uri.to_string()))?; map.insert(split, tx.clone()); } @@ -237,15 +263,11 @@ impl Builder { Self::default() } - pub fn add_handler( - &mut self, - uri: &str, - handler: impl RequestHandler, - ) -> Result<(), AddHandlerError> { + 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 { + pub fn subscribe(&mut self, uris: &[&str]) -> Result { subscribe(&mut self.message_handlers, uris) } @@ -289,7 +311,6 @@ impl DealerShared { if let Some(split) = split_uri(&msg.uri) { self.message_handlers .lock() - .unwrap() .retain(split, &mut |tx| tx.send(msg.clone()).is_ok()); } } @@ -309,7 +330,7 @@ impl DealerShared { }; { - let handler_map = self.request_handlers.lock().unwrap(); + let handler_map = self.request_handlers.lock(); if let Some(handler) = handler_map.get(split) { handler.handle_request(request, responder); @@ -328,7 +349,9 @@ impl DealerShared { } async fn closed(&self) { - self.notify_drop.acquire().await.unwrap_err(); + if self.notify_drop.acquire().await.is_ok() { + error!("should never have gotten a permit"); + } } fn is_closed(&self) -> bool { @@ -342,23 +365,19 @@ pub struct Dealer { } impl Dealer { - pub fn add_handler(&self, uri: &str, handler: H) -> Result<(), AddHandlerError> + pub fn add_handler(&self, uri: &str, handler: H) -> Result<(), Error> where H: RequestHandler, { - add_handler( - &mut self.shared.request_handlers.lock().unwrap(), - uri, - handler, - ) + 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().unwrap(), uri) + remove_handler(&mut self.shared.request_handlers.lock(), uri) } - pub fn subscribe(&self, uris: &[&str]) -> Result { - subscribe(&mut self.shared.message_handlers.lock().unwrap(), uris) + pub fn subscribe(&self, uris: &[&str]) -> Result { + subscribe(&mut self.shared.message_handlers.lock(), uris) } pub async fn close(mut self) { @@ -367,7 +386,9 @@ impl Dealer { self.shared.notify_drop.close(); if let Some(handle) = self.handle.take() { - CancelOnDrop(handle).await.unwrap(); + if let Err(e) = CancelOnDrop(handle).await { + error!("error aborting dealer operations: {}", e); + } } } } @@ -401,7 +422,7 @@ async fn connect( // Spawn a task that will forward messages from the channel to the websocket. let send_task = { - let shared = Arc::clone(&shared); + let shared = Arc::clone(shared); tokio::spawn(async move { let result = loop { @@ -450,7 +471,7 @@ async fn connect( }) }; - let shared = Arc::clone(&shared); + let shared = Arc::clone(shared); // A task that receives messages from the web socket. let receive_task = tokio::spawn(async { @@ -556,11 +577,15 @@ async fn run( select! { () = shared.closed() => break, r = t0 => { - r.unwrap(); // Whatever has gone wrong (probably panicked), we can't handle it, so let's panic too. + if let Err(e) = r { + error!("timeout on task 0: {}", e); + } tasks.0.take(); }, r = t1 => { - r.unwrap(); + if let Err(e) = r { + error!("timeout on task 1: {}", e); + } tasks.1.take(); } } @@ -576,7 +601,7 @@ async fn run( match connect(&url, proxy.as_ref(), &shared).await { Ok((s, r)) => tasks = (init_task(s), init_task(r)), Err(e) => { - warn!("Error while connecting: {}", e); + error!("Error while connecting: {}", e); tokio::time::sleep(RECONNECT_INTERVAL).await; } } 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..79969848 --- /dev/null +++ b/core/src/file_id.rs @@ -0,0 +1,55 @@ +use std::fmt; + +use librespot_protocol as protocol; + +use crate::spotify_id::to_base16; + +#[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) + } + + pub fn to_base16(&self) -> String { + 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()) + } +} + +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 index 5f8ef780..1cdfcf75 100644 --- a/core/src/http_client.rs +++ b/core/src/http_client.rs @@ -1,34 +1,180 @@ -use hyper::client::HttpConnector; -use hyper::{Body, Client, Request, Response}; +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; +use rustls::{ClientConfig, RootCertStore}; +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), + } + } + } + } +} + pub struct HttpClient { + user_agent: HeaderValue, proxy: Option, + tls_config: ClientConfig, } 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", + "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); + error!("Please report this as a bug."); + HeaderValue::from_static(FALLBACK_USER_AGENT) + }); + + // configuring TLS is expensive and should be done once per process + let root_store = match rustls_native_certs::load_native_certs() { + Ok(store) => store, + Err((Some(store), err)) => { + warn!("Could not load all certificates: {:?}", err); + store + } + Err((None, err)) => { + error!("Cannot access native certificate store: {}", err); + error!("Continuing, but most requests will probably fail until you fix your system certificate store."); + RootCertStore::empty() + } + }; + + let mut tls_config = ClientConfig::new(); + tls_config.root_store = root_store; + tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Self { + user_agent, proxy: proxy.cloned(), + tls_config, } } - pub async fn request(&self, req: Request) -> Result, hyper::Error> { - if let Some(url) = &self.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 + 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 { + pub async fn request_body(&self, req: Request) -> Result { let response = self.request(req).await?; - hyper::body::to_bytes(response.into_body()).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 mut http = HttpConnector::new(); + http.enforce_http(false); + + let https_connector = HttpsConnector::from((http, self.tls_config.clone())); + + 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(https_connector, proxy)?; + + Client::builder().build(proxy_connector).request(req) + } else { + Client::builder() + .http2_adaptive_window(true) + .build(https_connector) + .request(req) + }; + + Ok(request) } } diff --git a/core/src/lib.rs b/core/src/lib.rs index 9c92c235..a0f180ca 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -7,26 +7,37 @@ 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 error; +pub mod file_id; mod http_client; pub mod mercury; pub mod packet; mod proxytunnel; pub mod session; mod socket; -mod spclient; +#[allow(dead_code)] +pub mod spclient; pub mod spotify_id; -mod token; +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 6cf3519e..b693444a 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,9 +12,7 @@ use futures_util::FutureExt; use protobuf::Message; use tokio::sync::{mpsc, oneshot}; -use crate::packet::PacketType; -use crate::protocol; -use crate::util::SeqGenerator; +use crate::{packet::PacketType, protocol, util::SeqGenerator, Error}; mod types; pub use self::types::*; @@ -33,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)? } } @@ -55,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 { @@ -72,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(), @@ -87,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(), @@ -103,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 { @@ -115,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(); @@ -125,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 @@ -144,7 +152,28 @@ impl MercuryManager { } } - pub(crate) fn dispatch(&self, cmd: PacketType, 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(); @@ -164,7 +193,7 @@ impl MercuryManager { } } else { warn!("Ignore seq {:?} cmd {:x}", seq, cmd as u8); - return; + return Err(MercuryError::Command(cmd).into()); } } }; @@ -184,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 { @@ -195,9 +226,9 @@ impl MercuryManager { data.split_to(size).as_ref().to_owned() } - fn complete_request(&self, cmd: PacketType, 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(), @@ -205,13 +236,17 @@ 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)?; } + Err(MercuryError::Response(response).into()) } else if let PacketType::MercuryEvent = cmd { self.lock(|inner| { let mut found = false; @@ -221,7 +256,7 @@ impl MercuryManager { // 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()) + 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::() })) @@ -242,11 +277,19 @@ impl MercuryManager { }); if !found { - debug!("unknown subscription uri={}", response.uri); + 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 1d6b5b15..9c7593fe 100644 --- a/core/src/mercury/types.rs +++ b/core/src/mercury/types.rs @@ -1,9 +1,10 @@ -use byteorder::{BigEndian, WriteBytesExt}; -use protobuf::Message; use std::io::Write; -use crate::packet::PacketType; -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 { @@ -28,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 { @@ -55,14 +73,12 @@ impl MercuryMethod { } 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()); @@ -72,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_all(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 index de780f13..2f50d158 100644 --- a/core/src/packet.rs +++ b/core/src/packet.rs @@ -2,7 +2,7 @@ use num_derive::{FromPrimitive, ToPrimitive}; -#[derive(Debug, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)] pub enum PacketType { SecretBlock = 0x02, Ping = 0x04, diff --git a/core/src/session.rs b/core/src/session.rs index 81975a80..f1136e53 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -1,11 +1,16 @@ -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::{ + atomic::{AtomicUsize, Ordering}, + Arc, Weak, + }, + task::{Context, Poll}, + time::{SystemTime, UNIX_EPOCH}, +}; use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; @@ -13,21 +18,27 @@ 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::ApResolver; -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::http_client::HttpClient; -use crate::mercury::MercuryManager; -use crate::packet::PacketType; -use crate::token::TokenProvider; +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 { @@ -35,13 +46,35 @@ pub enum SessionError { AuthenticationError(#[from] AuthenticationError), #[error("Cannot create session: {0}")] IoError(#[from] io::Error), + #[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::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, + connection_id: String, time_delta: i64, - canonical_username: String, invalid: bool, + user_data: UserData, } struct SessionInternal { @@ -55,6 +88,7 @@ struct SessionInternal { audio_key: OnceCell, channel: OnceCell, mercury: OnceCell, + spclient: OnceCell, token_provider: OnceCell, cache: Option>, @@ -73,7 +107,7 @@ impl Session { config: SessionConfig, credentials: Credentials, cache: Option, - ) -> Result { + ) -> Result { let http_client = HttpClient::new(config.proxy.as_ref()); let (sender_tx, sender_rx) = mpsc::unbounded_channel(); let session_id = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); @@ -82,12 +116,7 @@ impl Session { let session = Session(Arc::new(SessionInternal { config, - data: RwLock::new(SessionData { - country: String::new(), - canonical_username: String::new(), - invalid: false, - time_delta: 0, - }), + data: RwLock::new(SessionData::default()), http_client, tx_connection: sender_tx, cache: cache.map(Arc::new), @@ -95,6 +124,7 @@ impl Session { audio_key: OnceCell::new(), channel: OnceCell::new(), mercury: OnceCell::new(), + spclient: OnceCell::new(), token_provider: OnceCell::new(), handle: tokio::runtime::Handle::current(), session_id, @@ -109,7 +139,7 @@ impl Session { connection::authenticate(&mut transport, credentials, &session.config().device_id) .await?; info!("Authenticated as \"{}\" !", reusable_credentials.username); - session.0.data.write().unwrap().canonical_username = reusable_credentials.username.clone(); + session.0.data.write().user_data.canonical_username = reusable_credentials.username.clone(); if let Some(cache) = session.cache() { cache.save_credentials(&reusable_credentials); } @@ -159,6 +189,10 @@ impl Session { .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 @@ -166,7 +200,7 @@ impl Session { } 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) @@ -186,9 +220,30 @@ impl Session { ); } - fn dispatch(&self, cmd: u8, data: Bytes) { + 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; @@ -198,43 +253,77 @@ 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(Pong, vec![0, 0, 0, 0]); + self.send_packet(Pong, vec![0, 0, 0, 0]) } Some(CountryCode) => { - let country = String::from_utf8(data.as_ref().to_owned()).unwrap(); + let country = String::from_utf8(data.as_ref().to_owned())?; info!("Country: {:?}", country); - self.0.data.write().unwrap().country = country; - } - Some(StreamChunkRes) | Some(ChannelError) => { - self.channel().dispatch(packet_type.unwrap(), data); - } - Some(AesKey) | Some(AesKeyError) => { - self.audio_key().dispatch(packet_type.unwrap(), data); + 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(packet_type.unwrap(), data); + self.mercury().dispatch(cmd, data) + } + Some(ProductInfo) => { + let data = std::str::from_utf8(&data)?; + let mut reader = quick_xml::Reader::from_str(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(ProductInfo) - | Some(LicenseVersion) => {} + | Some(LicenseVersion) => Ok(()), _ => { - if let Some(packet_type) = PacketType::from_u8(cmd) { - trace!("Ignoring {:?} packet with data {:?}", packet_type, data); - } else { - trace!("Ignoring unknown packet {:x}", cmd); - } + trace!("Ignoring {:?} packet with data {:#?}", cmd, data); + Err(SessionError::Packet(cmd as u8).into()) } } } - pub fn send_packet(&self, cmd: PacketType, data: Vec) { - self.0.tx_connection.send((cmd as u8, data)).unwrap(); + pub fn send_packet(&self, cmd: PacketType, data: Vec) -> Result<(), Error> { + self.0.tx_connection.send((cmd as u8, data))?; + Ok(()) } pub fn cache(&self) -> Option<&Arc> { @@ -245,18 +334,45 @@ impl Session { &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() + 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 connection_id(&self) -> String { + self.0.data.read().connection_id.clone() + } + + pub fn set_connection_id(&self, connection_id: String) { + self.0.data.write().connection_id = connection_id; + } + + pub fn username(&self) -> String { + self.0.data.read().user_data.canonical_username.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) + } + fn weak(&self) -> SessionWeak { SessionWeak(Arc::downgrade(&self.0)) } @@ -266,14 +382,14 @@ impl Session { } pub fn shutdown(&self) { - debug!("Invalidating session[{}]", self.0.session_id); - self.0.data.write().unwrap().invalid = true; + debug!("Invalidating session [{}]", self.0.session_id); + 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 } } @@ -286,7 +402,8 @@ 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") } } @@ -327,7 +444,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 index 92274cc6..84ac6024 100644 --- a/core/src/socket.rs +++ b/core/src/socket.rs @@ -1,5 +1,4 @@ -use std::io; -use std::net::ToSocketAddrs; +use std::{io, net::ToSocketAddrs}; use tokio::net::TcpStream; use url::Url; diff --git a/core/src/spclient.rs b/core/src/spclient.rs index eb7b3f0f..1adfa3f8 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -1 +1,293 @@ -// https://github.com/librespot-org/librespot-java/blob/27783e06f456f95228c5ac37acf2bff8c1a8a0c4/lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java +use std::time::Duration; + +use bytes::Bytes; +use futures_util::future::IntoStream; +use http::header::HeaderValue; +use hyper::{ + client::ResponseFuture, + header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE}, + Body, HeaderMap, Method, Request, +}; +use protobuf::Message; +use rand::Rng; + +use crate::{ + apresolve::SocketAddress, + cdn_url::CdnUrl, + error::ErrorKind, + protocol::{ + canvaz::EntityCanvazRequest, connect::PutStateRequest, + extended_metadata::BatchedEntityRequest, + }, + Error, FileId, SpotifyId, +}; + +component! { + SpClient : SpClientInner { + accesspoint: Option = None, + strategy: RequestStrategy = RequestStrategy::default(), + } +} + +pub type SpClientResult = Result; + +#[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) -> SocketAddress { + // Memoize the current access point. + let ap = self.lock(|inner| inner.accesspoint.clone()); + 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 + } + } + } + + pub async fn base_url(&self) -> String { + let ap = self.get_accesspoint().await; + format!("https://{}:{}", ap.0, ap.1) + } + + 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, "application/protobuf".parse()?); + + 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, "application/json".parse()?); + + 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_else(String::new); + + 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); + + 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 headers_mut = request.headers_mut(); + if let Some(ref hdrs) = headers { + *headers_mut = hdrs.clone(); + } + headers_mut.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!( + "Bearer {}", + self.session() + .token_provider() + .get_token("playlist-read") + .await? + .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) + } +} diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index e6e2bae0..b8a1448e 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -1,43 +1,81 @@ -#![allow(clippy::wrong_self_convention)] +use std::{ + convert::{TryFrom, TryInto}, + fmt, + ops::Deref, +}; -use std::convert::TryInto; -use std::fmt; +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"; @@ -47,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`. @@ -59,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() { @@ -89,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()), } } @@ -114,30 +162,37 @@ 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) -> String { - to_base16(&self.to_raw(), &mut [0u8; SpotifyId::SIZE_BASE16]) + to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16]) } /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22) @@ -190,7 +245,7 @@ impl SpotifyId { /// 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] { + pub fn to_raw(&self) -> [u8; Self::SIZE] { self.id.to_be_bytes() } @@ -204,11 +259,11 @@ impl SpotifyId { /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids pub fn to_uri(&self) -> String { // 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(':'); dst.push_str(&self.to_base62()); @@ -216,29 +271,231 @@ impl SpotifyId { } } -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct FileId(pub [u8; 20]); - -impl FileId { - pub fn to_base16(&self) -> String { - 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()).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()) + f.write_str(&self.to_uri()) } } -#[inline] -fn to_base16(src: &[u8], buf: &mut [u8]) -> String { +#[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) -> String { + 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(':'); + dst.push_str(&self.to_base62()); + + 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()) + .finish() + } +} + +impl fmt::Display for NamedSpotifyId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.inner_id.to_uri()) + } +} + +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]) -> String { let mut i = 0; for v in src { buf[i] = BASE16_DIGITS[(v >> 4) as usize]; @@ -258,7 +515,7 @@ mod tests { struct ConversionCase { id: u128, - kind: SpotifyAudioType, + kind: SpotifyItemType, uri: &'static str, base16: &'static str, base62: &'static str, @@ -268,7 +525,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", @@ -278,7 +535,7 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::Track, + kind: SpotifyItemType::Track, uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", @@ -288,7 +545,7 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::Podcast, + kind: SpotifyItemType::Episode, uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", @@ -298,8 +555,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: &[ @@ -311,7 +568,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", @@ -323,7 +580,7 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, + kind: SpotifyItemType::Unknown, // Missing colon between ID and type. uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", base16: "--------------------", @@ -335,7 +592,7 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, + kind: SpotifyItemType::Unknown, // Uri too short uri: "spotify:azb:aRS48xBl0tH", base16: "--------------------", @@ -354,7 +611,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(),); } } @@ -363,7 +620,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(), c.base62); @@ -377,7 +634,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(),); } } @@ -386,7 +643,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(), c.base16); @@ -399,11 +656,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()); } } @@ -412,7 +669,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(), c.uri); @@ -426,7 +683,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 index 824fcc3b..0c0b7394 100644 --- a/core/src/token.rs +++ b/core/src/token.rs @@ -8,12 +8,12 @@ // user-library-modify, user-library-read, user-follow-modify, user-follow-read, streaming, // app-remote-control -use crate::mercury::MercuryError; +use std::time::{Duration, Instant}; use serde::Deserialize; +use thiserror::Error; -use std::error::Error; -use std::time::{Duration, Instant}; +use crate::Error; component! { TokenProvider : TokenProviderInner { @@ -21,13 +21,25 @@ component! { } } +#[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 { - access_token: String, - expires_in: Duration, - token_type: String, - scopes: Vec, - timestamp: Instant, + pub access_token: String, + pub expires_in: Duration, + pub token_type: String, + pub scopes: Vec, + pub timestamp: Instant, } #[derive(Deserialize)] @@ -54,11 +66,7 @@ impl TokenProvider { } // scopes must be comma-separated - pub async fn get_token(&self, scopes: &str) -> Result { - if scopes.is_empty() { - return Err(MercuryError); - } - + pub async fn get_token(&self, scopes: &str) -> Result { 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() { @@ -79,15 +87,11 @@ impl TokenProvider { Self::KEYMASTER_CLIENT_ID, self.session().device_id() ); - let request = self.session().mercury().get(query_uri); + let request = self.session().mercury().get(query_uri)?; let response = request.await?; - let data = response - .payload - .first() - .expect("No tokens received") - .to_vec(); - let token = Token::new(String::from_utf8(data).unwrap()).map_err(|_| MercuryError)?; - trace!("Got token: {:?}", token); + let data = response.payload.first().ok_or(TokenError::Empty)?.to_vec(); + let token = Token::new(String::from_utf8(data)?)?; + trace!("Got token: {:#?}", token); self.lock(|inner| inner.tokens.push(token.clone())); Ok(token) } @@ -96,7 +100,7 @@ impl TokenProvider { impl Token { const EXPIRY_THRESHOLD: Duration = Duration::from_secs(10); - pub fn new(body: String) -> Result> { + pub fn new(body: String) -> Result { let data: TokenData = serde_json::from_slice(body.as_ref())?; Ok(Self { access_token: data.access_token, diff --git a/core/src/util.rs b/core/src/util.rs index 4f78c467..a01f8b56 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -1,15 +1,13 @@ -use std::future::Future; -use std::mem; -use std::pin::Pin; -use std::task::Context; -use std::task::Poll; +use std::{ + future::Future, + mem, + pin::Pin, + task::{Context, Poll}, +}; use futures_core::ready; -use futures_util::FutureExt; -use futures_util::Sink; -use futures_util::{future, SinkExt}; -use tokio::task::JoinHandle; -use tokio::time::timeout; +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. diff --git a/core/src/version.rs b/core/src/version.rs index ef553463..98047ef1 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -15,3 +15,12 @@ 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 8b95e437..19d7977e 100644 --- a/core/tests/connect.rs +++ b/core/tests/connect.rs @@ -18,7 +18,7 @@ async fn test_connection() { 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 9ea9df48..0225ab68 100644 --- a/discovery/Cargo.toml +++ b/discovery/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librespot-discovery" -version = "0.2.0" +version = "0.3.1" authors = ["Paul Lietar "] description = "The discovery logic for librespot" license = "MIT" @@ -11,30 +11,28 @@ edition = "2018" aes-ctr = "0.6" base64 = "0.13" cfg-if = "1.0" +dns-sd = { version = "0.1.3", optional = true } form_urlencoded = "1.0" futures-core = "0.3" +futures-util = "0.3" hmac = "0.11" -hyper = { version = "0.14", features = ["server", "http1", "tcp"] } +hyper = { version = "0.14", features = ["http1", "server", "tcp"] } libmdns = "0.6" log = "0.4" rand = "0.8" serde_json = "1.0.25" sha-1 = "0.9" thiserror = "1.0" -tokio = { version = "1.0", features = ["sync", "rt"] } - -dns-sd = { version = "0.1.3", optional = true } +tokio = { version = "1.0", features = ["parking_lot", "sync", "rt"] } [dependencies.librespot-core] path = "../core" -default_features = false -version = "0.2.0" +version = "0.3.1" [dev-dependencies] futures = "0.3" hex = "0.4" -simple_logger = "1.11" -tokio = { version = "1.0", features = ["macros", "rt"] } +tokio = { version = "1.0", 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 b1249a0d..a29b3b8c 100644 --- a/discovery/src/lib.rs +++ b/discovery/src/lib.rs @@ -7,8 +7,6 @@ //! 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; @@ -29,6 +27,8 @@ pub use crate::core::authentication::Credentials; /// Determining the icon in the list of available devices. pub use crate::core::config::DeviceType; +pub use crate::core::Error; + /// Makes this device visible to Spotify clients in the local network. /// /// `Discovery` implements the [`Stream`] trait. Every time this device @@ -50,13 +50,28 @@ pub struct Builder { /// Errors that can occur while setting up a [`Discovery`] instance. #[derive(Debug, Error)] -pub enum Error { +pub enum DiscoveryError { /// Setting up service discovery via DNS-SD failed. #[error("Setting up dns-sd failed: {0}")] DnsSdError(#[from] io::Error), /// Setting up the http server failed. + #[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::DnsSdError(_) => Error::unavailable(err), + DiscoveryError::HmacError(_) => Error::invalid_argument(err), + DiscoveryError::HttpServerError(_) => Error::unavailable(err), + DiscoveryError::ParamsError(_) => Error::invalid_argument(err), + } + } } impl Builder { @@ -98,7 +113,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)??; let svc; @@ -111,8 +126,7 @@ impl Builder { None, port, &["VERSION=1.0", "CPath=/"], - ) - .unwrap(); + )?; } else { let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?; diff --git a/discovery/src/server.rs b/discovery/src/server.rs index 53b849f7..74af6fa3 100644 --- a/discovery/src/server.rs +++ b/discovery/src/server.rs @@ -1,26 +1,35 @@ -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_ctr::{ + cipher::generic_array::GenericArray, + cipher::{NewStreamCipher, SyncStreamCipher}, + Aes128Ctr, +}; use futures_core::Stream; +use futures_util::{FutureExt, TryFutureExt}; 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 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::{ + authentication::Credentials, config::DeviceType, diffie_hellman::DhLocalKeys, Error, +}; type Params<'a> = BTreeMap, Cow<'a, str>>; @@ -57,7 +66,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,14 +85,26 @@ 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 iv = &encrypted_blob[0..16]; @@ -94,21 +115,21 @@ impl RequestHandler { 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() { warn!("Login error for user {:?}: MAC mismatch", username); @@ -119,7 +140,7 @@ impl RequestHandler { }); let body = result.to_string(); - return Response::new(Body::from(body)); + return Ok(Response::new(Body::from(body))); } let decrypted = { @@ -129,13 +150,12 @@ impl RequestHandler { GenericArray::from_slice(iv), ); cipher.apply_keystream(&mut data); - String::from_utf8(data).unwrap() + data }; - let credentials = - Credentials::with_blob(username.to_string(), &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, @@ -144,7 +164,7 @@ impl RequestHandler { }); let body = result.to_string(); - Response::new(Body::from(body)) + Ok(Response::new(Body::from(body))) } fn not_found(&self) -> Response { @@ -153,7 +173,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(); @@ -173,11 +196,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(), - }) + })) } } @@ -187,7 +210,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); @@ -198,7 +221,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 + })) } }); @@ -210,8 +240,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; @@ -220,10 +252,10 @@ impl DiscoveryServer { } }); - Ok(Self { + Ok(Ok(Self { cred_rx, _close_tx: close_tx, - }) + })) } } diff --git a/docs/authentication.md b/docs/authentication.md index 86470161..2eeb5645 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -57,20 +57,4 @@ login_data = AES192-DECRYPT(key, data) ``` ## Facebook based Authentication -The client starts an HTTPS server, and makes the user visit -`https://login.spotify.com/login-facebook-sso/?csrf=CSRF&port=PORT` -in their browser, where CSRF is a random token, and PORT is the HTTPS server's port. - -This will redirect to Facebook, where the user must login and authorize Spotify, and -finally make a GET request to -`https://login.spotilocal.com:PORT/login/facebook_login_sso.json?csrf=CSRF&access_token=TOKEN`, -where PORT and CSRF are the same as sent earlier, and TOKEN is the facebook authentication token. - -Since `login.spotilocal.com` resolves the 127.0.0.1, the request is received by the client. - -The client must then contact Facebook's API at -`https://graph.facebook.com/me?fields=id&access_token=TOKEN` -in order to retrieve the user's Facebook ID. - -The Facebook ID is the `username`, the TOKEN the `auth_data`, and `auth_type` is set to `AUTHENTICATION_FACEBOOK_TOKEN`. - +Facebook authentication is currently broken due to Spotify changing the authentication flow. The details of how the new flow works are detailed in https://github.com/librespot-org/librespot/issues/244 and will be implemented at some point in the future. diff --git a/examples/playlist_tracks.rs b/examples/playlist_tracks.rs index 75c656bb..0b19e73e 100644 --- a/examples/playlist_tracks.rs +++ b/examples/playlist_tracks.rs @@ -30,7 +30,7 @@ async fn main() { 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 6e181a1a..c9f108d6 100644 --- a/metadata/Cargo.toml +++ b/metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librespot-metadata" -version = "0.2.0" +version = "0.3.1" authors = ["Paul Lietar "] description = "The metadata logic for librespot" license = "MIT" @@ -10,12 +10,17 @@ edition = "2018" [dependencies] async-trait = "0.1" byteorder = "1.3" -protobuf = "2.14.0" +bytes = "1.0" +chrono = "0.4" log = "0.4" +protobuf = "2.14.0" +thiserror = "1" +uuid = { version = "0.8", default-features = false } [dependencies.librespot-core] path = "../core" -version = "0.2.0" +version = "0.3.1" + [dependencies.librespot-protocol] path = "../protocol" -version = "0.2.0" +version = "0.3.1" diff --git a/metadata/src/album.rs b/metadata/src/album.rs new file mode 100644 index 00000000..6e07ed7e --- /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().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().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().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..d3ce69b7 --- /dev/null +++ b/metadata/src/audio/file.rs @@ -0,0 +1,28 @@ +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 From<&[AudioFileMessage]> for AudioFiles { + fn from(files: &[AudioFileMessage]) -> Self { + let audio_files = files + .iter() + .map(|file| (file.get_format(), FileId::from(file.get_file_id()))) + .collect(); + + AudioFiles(audio_files) + } +} diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs new file mode 100644 index 00000000..2b1f4eba --- /dev/null +++ b/metadata/src/audio/item.rs @@ -0,0 +1,112 @@ +use std::fmt::Debug; + +use chrono::Local; + +use crate::{ + availability::{AudioItemAvailability, Availabilities, UnavailabilityReason}, + episode::Episode, + error::MetadataError, + restriction::Restrictions, + track::{Track, Tracks}, +}; + +use super::file::AudioFiles; + +use librespot_core::{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, +} + +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| Local::now() >= availability.start.as_utc())) + { + 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..cc4efef0 --- /dev/null +++ b/metadata/src/audio/mod.rs @@ -0,0 +1,5 @@ +pub mod file; +pub mod item; + +pub use file::AudioFileFormat; +pub use item::AudioItem; diff --git a/metadata/src/availability.rs b/metadata/src/availability.rs new file mode 100644 index 00000000..d4681c28 --- /dev/null +++ b/metadata/src/availability.rs @@ -0,0 +1,51 @@ +use std::{fmt::Debug, ops::Deref}; + +use thiserror::Error; + +use crate::util::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 From<&AvailabilityMessage> for Availability { + fn from(availability: &AvailabilityMessage) -> Self { + Self { + catalogue_strs: availability.get_catalogue_str().to_vec(), + start: availability.get_start().into(), + } + } +} + +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 b483f454..00000000 --- a/metadata/src/cover.rs +++ /dev/null @@ -1,20 +0,0 @@ -use byteorder::{BigEndian, WriteBytesExt}; -use std::io::Write; - -use librespot_core::channel::ChannelData; -use librespot_core::packet::PacketType; -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(PacketType::Image, packet); - - data -} diff --git a/metadata/src/episode.rs b/metadata/src/episode.rs new file mode 100644 index 00000000..30aae5a8 --- /dev/null +++ b/metadata/src/episode.rs @@ -0,0 +1,132 @@ +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, + }) + } +} + +#[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().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().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 e7595f59..577af387 100644 --- a/metadata/src/lib.rs +++ b/metadata/src/lib.rs @@ -1,460 +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 librespot_core::mercury::MercuryError; -use librespot_core::session::Session; -use librespot_core::spotify_id::{FileId, SpotifyAudioType, SpotifyId}; -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 { - let item = Self::get(session, id).await?; - Ok(AudioItem { - id, - uri: format!("spotify:track:{}", id.to_base62()), - 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 { - let item = Self::get(session, id).await?; - - Ok(AudioItem { - id, - uri: format!("spotify:episode:{}", id.to_base62()), - 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) -> String; - fn parse(msg: &Self::Message, session: &Session) -> Self; + // Request a protobuf + async fn request(session: &Session, id: SpotifyId) -> RequestResult; - async fn get(session: &Session, id: SpotifyId) -> Result { - let uri = Self::request_url(id); - let response = session.mercury().get(uri).await?; - let data = response.payload.first().expect("Empty payload"); - let msg = Self::Message::parse_from_bytes(data).unwrap(); - - Ok(Self::parse(&msg, &session)) - } -} - -#[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) -> String { - format!("hm://metadata/3/track/{}", id.to_base16()) + // 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) -> Self { - let country = session.country(); - - let artists = msg - .get_artist() - .iter() - .filter(|artist| artist.has_gid()) - .map(|artist| SpotifyId::from_raw(artist.get_gid()).unwrap()) - .collect::>(); - - 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(); - - Track { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - duration: msg.get_duration(), - album: SpotifyId::from_raw(msg.get_album().get_gid()).unwrap(), - artists, - files, - alternatives: msg - .get_alternative() - .iter() - .map(|alt| SpotifyId::from_raw(alt.get_gid()).unwrap()) - .collect(), - available: parse_restrictions(msg.get_restriction(), &country, "premium"), - } - } -} - -impl Metadata for Album { - type Message = protocol::metadata::Album; - - fn request_url(id: SpotifyId) -> String { - format!("hm://metadata/3/album/{}", id.to_base16()) - } - - fn parse(msg: &Self::Message, _: &Session) -> Self { - let artists = msg - .get_artist() - .iter() - .filter(|artist| artist.has_gid()) - .map(|artist| SpotifyId::from_raw(artist.get_gid()).unwrap()) - .collect::>(); - - let tracks = msg - .get_disc() - .iter() - .flat_map(|disc| disc.get_track()) - .filter(|track| track.has_gid()) - .map(|track| SpotifyId::from_raw(track.get_gid()).unwrap()) - .collect::>(); - - let covers = msg - .get_cover_group() - .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::>(); - - Album { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - artists, - tracks, - covers, - } - } -} - -impl Metadata for Playlist { - type Message = protocol::playlist4changes::SelectedListContent; - - fn request_url(id: SpotifyId) -> String { - format!("hm://playlist/v2/playlist/{}", id.to_base62()) - } - - fn parse(msg: &Self::Message, _: &Session) -> Self { - let tracks = msg - .get_contents() - .get_items() - .iter() - .map(|item| { - let uri_split = item.get_uri().split(':'); - let uri_parts: Vec<&str> = uri_split.collect(); - SpotifyId::from_base62(uri_parts[2]).unwrap() - }) - .collect::>(); - - if tracks.len() != msg.get_length() as usize { - warn!( - "Got {} tracks, but the playlist should contain {} tracks.", - tracks.len(), - msg.get_length() - ); - } - - 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) -> String { - format!("hm://metadata/3/artist/{}", id.to_base16()) - } - - fn parse(msg: &Self::Message, session: &Session) -> Self { - 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(|track| track.has_gid()) - .map(|track| SpotifyId::from_raw(track.get_gid()).unwrap()) - .collect::>(), - None => Vec::new(), - }; - - Artist { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - top_tracks, - } - } -} - -// Podcast -impl Metadata for Episode { - type Message = protocol::metadata::Episode; - - fn request_url(id: SpotifyId) -> String { - format!("hm://metadata/3/episode/{}", id.to_base16()) - } - - fn parse(msg: &Self::Message, session: &Session) -> Self { - let country = session.country(); - - let files = msg - .get_audio() - .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_cover_image() - .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::>(); - - 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) -> String { - format!("hm://metadata/3/show/{}", id.to_base16()) - } - - fn parse(msg: &Self::Message, _: &Session) -> Self { - let episodes = msg - .get_episode() - .iter() - .filter(|episode| episode.has_gid()) - .map(|episode| SpotifyId::from_raw(episode.get_gid()).unwrap()) - .collect::>(); - - let covers = msg - .get_cover_image() - .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::>(); - - 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..587f9b39 --- /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..eb4fb577 --- /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, SpotifyId}; + +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: SpotifyId, + 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: SpotifyId, +} + +#[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().try_into()?, + 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: attributes.get_timestamp().try_into()?, + seen_at: attributes.get_seen_at().try_into()?, + is_public: attributes.get_public(), + format_attributes: attributes.get_format_attributes().into(), + item_id: attributes.get_item_id().try_into()?, + }) + } +} +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..dbd5fda2 --- /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: item.get_timestamp().try_into()?, + 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..612ef857 --- /dev/null +++ b/metadata/src/playlist/list.rs @@ -0,0 +1,225 @@ +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: SpotifyId, + pub length: i32, + pub attributes: PlaylistAttributes, + pub contents: PlaylistItemList, + pub diff: PlaylistDiff, + pub sync_result: PlaylistDiff, + 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: SpotifyId, + pub length: i32, + pub attributes: PlaylistAttributes, + pub contents: PlaylistItemList, + pub diff: PlaylistDiff, + pub sync_result: PlaylistDiff, + 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().try_into()?, + length: playlist.get_length(), + attributes: playlist.get_attributes().try_into()?, + contents: playlist.get_contents().try_into()?, + diff: playlist.get_diff().try_into()?, + sync_result: playlist.get_sync_result().try_into()?, + 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: playlist.get_timestamp().try_into()?, + 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..2ebd4037 --- /dev/null +++ b/metadata/src/request.rs @@ -0,0 +1,21 @@ +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 request = session.mercury().get(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..af6b58ac --- /dev/null +++ b/metadata/src/sale_period.rs @@ -0,0 +1,37 @@ +use std::{fmt::Debug, ops::Deref}; + +use crate::{restriction::Restrictions, util::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 From<&SalePeriodMessage> for SalePeriod { + fn from(sale_period: &SalePeriodMessage) -> Self { + Self { + restrictions: sale_period.get_restriction().into(), + start: sale_period.get_start().into(), + end: sale_period.get_end().into(), + } + } +} + +from_repeated_message!(SalePeriodMessage, SalePeriods); diff --git a/metadata/src/show.rs b/metadata/src/show.rs new file mode 100644 index 00000000..9f84ba21 --- /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().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..06efd310 --- /dev/null +++ b/metadata/src/track.rs @@ -0,0 +1,149 @@ +use std::{ + convert::{TryFrom, TryInto}, + fmt::Debug, + ops::Deref, +}; + +use chrono::Local; +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 Local::now() < track.earliest_live_timestamp.as_utc() { + 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, + }) + } +} + +#[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().into(), + previews: track.get_preview().into(), + tags: track.get_tags().to_vec(), + earliest_live_timestamp: track.get_earliest_live_timestamp().try_into()?, + has_lyrics: track.get_has_lyrics(), + availability: track.get_availability().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 0bed793c..fee4dd51 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librespot-playback" -version = "0.2.0" +version = "0.3.1" authors = ["Sasha Hilton "] description = "The audio playback logic for librespot" license = "MIT" @@ -9,13 +9,13 @@ edition = "2018" [dependencies.librespot-audio] path = "../audio" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-core] path = "../core" -version = "0.2.0" +version = "0.3.1" [dependencies.librespot-metadata] path = "../metadata" -version = "0.2.0" +version = "0.3.1" [dependencies] futures-executor = "0.3" @@ -23,7 +23,8 @@ futures-util = { version = "0.3", default_features = false, features = ["alloc"] log = "0.4" byteorder = "1.4" shell-words = "1.0.0" -tokio = { version = "1", features = ["sync"] } +thiserror = "1.0" +tokio = { version = "1", features = ["parking_lot", "rt", "rt-multi-thread", "sync"] } zerocopy = { version = "0.3" } # Backends @@ -40,22 +41,21 @@ glib = { version = "0.10", optional = true } # Rodio dependencies rodio = { version = "0.14", optional = true, default-features = false } cpal = { version = "0.13", optional = true } -thiserror = { version = "1", optional = true } # Decoder lewton = "0.10" ogg = "0.8" # Dithering -rand = "0.8" +rand = { version = "0.8", features = ["small_rng"] } rand_distr = "0.4" [features] -alsa-backend = ["alsa", "thiserror"] +alsa-backend = ["alsa"] portaudio-backend = ["portaudio-rs"] pulseaudio-backend = ["libpulse-binding", "libpulse-simple-binding"] jackaudio-backend = ["jack"] -rodio-backend = ["rodio", "cpal", "thiserror"] -rodiojack-backend = ["rodio", "cpal/jack", "thiserror"] +rodio-backend = ["rodio", "cpal"] +rodiojack-backend = ["rodio", "cpal/jack"] sdl-backend = ["sdl2"] gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"] diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index 7101f96d..4f82a097 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -1,51 +1,102 @@ -use super::{Open, Sink, SinkAsBytes}; +use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::{NUM_CHANNELS, SAMPLE_RATE}; use alsa::device_name::HintIter; -use alsa::pcm::{Access, Format, HwParams, PCM}; +use alsa::pcm::{Access, Format, Frames, HwParams, PCM}; use alsa::{Direction, ValueOr}; use std::cmp::min; -use std::io; use std::process::exit; -use std::time::Duration; use thiserror::Error; -// 125 ms Period time * 4 periods = 0.5 sec buffer. -const PERIOD_TIME: Duration = Duration::from_millis(125); -const NUM_PERIODS: u32 = 4; +const MAX_BUFFER: Frames = (SAMPLE_RATE / 2) as Frames; +const MIN_BUFFER: Frames = (SAMPLE_RATE / 10) as Frames; +const ZERO_FRAMES: Frames = 0; + +const MAX_PERIOD_DIVISOR: Frames = 4; +const MIN_PERIOD_DIVISOR: Frames = 10; #[derive(Debug, Error)] enum AlsaError { - #[error("AlsaSink, device {device} may be invalid or busy, {err}")] - PcmSetUp { device: String, err: alsa::Error }, - #[error("AlsaSink, device {device} unsupported access type RWInterleaved, {err}")] - UnsupportedAccessType { device: String, err: alsa::Error }, - #[error("AlsaSink, device {device} unsupported format {format:?}, {err}")] + #[error(" Device {device} Unsupported Format {alsa_format:?} ({format:?}), {e}")] UnsupportedFormat { device: String, + alsa_format: Format, format: AudioFormat, - err: alsa::Error, + e: alsa::Error, }, - #[error("AlsaSink, device {device} unsupported sample rate {samplerate}, {err}")] - UnsupportedSampleRate { - device: String, - samplerate: u32, - err: alsa::Error, - }, - #[error("AlsaSink, device {device} unsupported channel count {channel_count}, {err}")] + + #[error(" Device {device} Unsupported Channel Count {channel_count}, {e}")] UnsupportedChannelCount { device: String, channel_count: u8, - err: alsa::Error, + e: alsa::Error, }, - #[error("AlsaSink Hardware Parameters Error, {0}")] + + #[error(" Device {device} Unsupported Sample Rate {samplerate}, {e}")] + UnsupportedSampleRate { + device: String, + samplerate: u32, + e: alsa::Error, + }, + + #[error(" Device {device} Unsupported Access Type RWInterleaved, {e}")] + UnsupportedAccessType { device: String, e: alsa::Error }, + + #[error(" Device {device} May be Invalid, Busy, or Already in Use, {e}")] + PcmSetUp { device: String, e: alsa::Error }, + + #[error(" Failed to Drain PCM Buffer, {0}")] + DrainFailure(alsa::Error), + + #[error(" {0}")] + OnWrite(alsa::Error), + + #[error(" Hardware, {0}")] HwParams(alsa::Error), - #[error("AlsaSink Software Parameters Error, {0}")] + + #[error(" Software, {0}")] SwParams(alsa::Error), - #[error("AlsaSink PCM Error, {0}")] + + #[error(" PCM, {0}")] Pcm(alsa::Error), + + #[error(" Could Not Parse Ouput Name(s) and/or Description(s)")] + Parsing, + + #[error("")] + NotConnected, +} + +impl From for SinkError { + fn from(e: AlsaError) -> SinkError { + use AlsaError::*; + let es = e.to_string(); + match e { + DrainFailure(_) | OnWrite(_) => SinkError::OnWrite(es), + PcmSetUp { .. } => SinkError::ConnectionRefused(es), + NotConnected => SinkError::NotConnected(es), + _ => SinkError::InvalidParams(es), + } + } +} + +impl From for Format { + fn from(f: AudioFormat) -> Format { + use AudioFormat::*; + match f { + F64 => Format::float64(), + F32 => Format::float(), + S32 => Format::s32(), + S24 => Format::s24(), + S16 => Format::s16(), + #[cfg(target_endian = "little")] + S24_3 => Format::S243LE, + #[cfg(target_endian = "big")] + S24_3 => Format::S243BE, + } + } } pub struct AlsaSink { @@ -55,26 +106,50 @@ pub struct AlsaSink { period_buffer: Vec, } -fn list_outputs() -> io::Result<()> { - println!("Listing available Alsa outputs:"); - for t in &["pcm", "ctl", "hwdep"] { - println!("{} devices:", t); - let i = match HintIter::new_str(None, &t) { - Ok(i) => i, - Err(e) => { - return Err(io::Error::new(io::ErrorKind::Other, e)); - } - }; - for a in i { - if let Some(Direction::Playback) = a.direction { - // mimic aplay -L - let name = a - .name - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not parse name"))?; - let desc = a - .desc - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not parse desc"))?; - println!("{}\n\t{}\n", name, desc.replace("\n", "\n\t")); +fn list_compatible_devices() -> SinkResult<()> { + println!("\n\n\tCompatible alsa device(s):\n"); + println!("\t------------------------------------------------------\n"); + + let i = HintIter::new_str(None, "pcm").map_err(|_| AlsaError::Parsing)?; + + for a in i { + if let Some(Direction::Playback) = a.direction { + let name = a.name.ok_or(AlsaError::Parsing)?; + let desc = a.desc.ok_or(AlsaError::Parsing)?; + + if let Ok(pcm) = PCM::new(&name, Direction::Playback, false) { + if let Ok(hwp) = HwParams::any(&pcm) { + // Only show devices that support + // 2 ch 44.1 Interleaved. + if hwp.set_access(Access::RWInterleaved).is_ok() + && hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).is_ok() + && hwp.set_channels(NUM_CHANNELS as u32).is_ok() + { + println!("\tDevice:\n\n\t\t{}\n", name); + println!("\tDescription:\n\n\t\t{}\n", desc.replace("\n", "\n\t\t")); + + let mut supported_formats = vec![]; + + for f in &[ + AudioFormat::S16, + AudioFormat::S24, + AudioFormat::S24_3, + AudioFormat::S32, + AudioFormat::F32, + AudioFormat::F64, + ] { + if hwp.test_format(Format::from(*f)).is_ok() { + supported_formats.push(format!("{:?}", f)); + } + } + + println!( + "\tSupported Format(s):\n\n\t\t{}\n", + supported_formats.join(" ") + ); + println!("\t------------------------------------------------------\n"); + } + }; } } } @@ -82,45 +157,36 @@ fn list_outputs() -> io::Result<()> { Ok(()) } -fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), AlsaError> { +fn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> { let pcm = PCM::new(dev_name, Direction::Playback, false).map_err(|e| AlsaError::PcmSetUp { device: dev_name.to_string(), - err: e, + e, })?; - let alsa_format = match format { - AudioFormat::F64 => Format::float64(), - AudioFormat::F32 => Format::float(), - AudioFormat::S32 => Format::s32(), - AudioFormat::S24 => Format::s24(), - AudioFormat::S16 => Format::s16(), - - #[cfg(target_endian = "little")] - AudioFormat::S24_3 => Format::S243LE, - #[cfg(target_endian = "big")] - AudioFormat::S24_3 => Format::S243BE, - }; - let bytes_per_period = { let hwp = HwParams::any(&pcm).map_err(AlsaError::HwParams)?; + hwp.set_access(Access::RWInterleaved) .map_err(|e| AlsaError::UnsupportedAccessType { device: dev_name.to_string(), - err: e, + e, })?; + let alsa_format = Format::from(format); + hwp.set_format(alsa_format) .map_err(|e| AlsaError::UnsupportedFormat { device: dev_name.to_string(), + alsa_format, format, - err: e, + e, })?; hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).map_err(|e| { AlsaError::UnsupportedSampleRate { device: dev_name.to_string(), samplerate: SAMPLE_RATE, - err: e, + e, } })?; @@ -128,47 +194,209 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Alsa .map_err(|e| AlsaError::UnsupportedChannelCount { device: dev_name.to_string(), channel_count: NUM_CHANNELS, - err: e, + e, })?; - // Deal strictly in time and periods. - hwp.set_periods(NUM_PERIODS, ValueOr::Nearest) - .map_err(AlsaError::HwParams)?; + // Clone the hwp while it's in + // a good working state so that + // in the event of an error setting + // the buffer and period sizes + // we can use the good working clone + // instead of the hwp that's in an + // error state. + let hwp_clone = hwp.clone(); - hwp.set_period_time_near(PERIOD_TIME.as_micros() as u32, ValueOr::Nearest) - .map_err(AlsaError::HwParams)?; + // At a sampling rate of 44100: + // The largest buffer is 22050 Frames (500ms) with 5512 Frame periods (125ms). + // The smallest buffer is 4410 Frames (100ms) with 441 Frame periods (10ms). + // Actual values may vary. + // + // Larger buffer and period sizes are preferred as extremely small values + // will cause high CPU useage. + // + // If no buffer or period size is in those ranges or an error happens + // trying to set the buffer or period size use the device's defaults + // which may not be ideal but are *hopefully* serviceable. - pcm.hw_params(&hwp).map_err(AlsaError::Pcm)?; + let buffer_size = { + let max = match hwp.get_buffer_size_max() { + Err(e) => { + trace!("Error getting the device's max Buffer size: {}", e); + ZERO_FRAMES + } + Ok(s) => s, + }; - let swp = pcm.sw_params_current().map_err(AlsaError::Pcm)?; + let min = match hwp.get_buffer_size_min() { + Err(e) => { + trace!("Error getting the device's min Buffer size: {}", e); + ZERO_FRAMES + } + Ok(s) => s, + }; - // Don't assume we got what we wanted. - // Ask to make sure. + let buffer_size = if min < max { + match (MIN_BUFFER..=MAX_BUFFER) + .rev() + .find(|f| (min..=max).contains(f)) + { + Some(size) => { + trace!("Desired Frames per Buffer: {:?}", size); + + match hwp.set_buffer_size_near(size) { + Err(e) => { + trace!("Error setting the device's Buffer size: {}", e); + ZERO_FRAMES + } + Ok(s) => s, + } + } + None => { + trace!("No Desired Buffer size in range reported by the device."); + ZERO_FRAMES + } + } + } else { + trace!("The device's min reported Buffer size was greater than or equal to it's max reported Buffer size."); + ZERO_FRAMES + }; + + if buffer_size == ZERO_FRAMES { + trace!( + "Desired Buffer Frame range: {:?} - {:?}", + MIN_BUFFER, + MAX_BUFFER + ); + + trace!( + "Actual Buffer Frame range as reported by the device: {:?} - {:?}", + min, + max + ); + } + + buffer_size + }; + + let period_size = { + if buffer_size == ZERO_FRAMES { + ZERO_FRAMES + } else { + let max = match hwp.get_period_size_max() { + Err(e) => { + trace!("Error getting the device's max Period size: {}", e); + ZERO_FRAMES + } + Ok(s) => s, + }; + + let min = match hwp.get_period_size_min() { + Err(e) => { + trace!("Error getting the device's min Period size: {}", e); + ZERO_FRAMES + } + Ok(s) => s, + }; + + let max_period = buffer_size / MAX_PERIOD_DIVISOR; + let min_period = buffer_size / MIN_PERIOD_DIVISOR; + + let period_size = if min < max && min_period < max_period { + match (min_period..=max_period) + .rev() + .find(|f| (min..=max).contains(f)) + { + Some(size) => { + trace!("Desired Frames per Period: {:?}", size); + + match hwp.set_period_size_near(size, ValueOr::Nearest) { + Err(e) => { + trace!("Error setting the device's Period size: {}", e); + ZERO_FRAMES + } + Ok(s) => s, + } + } + None => { + trace!("No Desired Period size in range reported by the device."); + ZERO_FRAMES + } + } + } else { + trace!("The device's min reported Period size was greater than or equal to it's max reported Period size,"); + trace!("or the desired min Period size was greater than or equal to the desired max Period size."); + ZERO_FRAMES + }; + + if period_size == ZERO_FRAMES { + trace!("Buffer size: {:?}", buffer_size); + + trace!( + "Desired Period Frame range: {:?} (Buffer size / {:?}) - {:?} (Buffer size / {:?})", + min_period, + MIN_PERIOD_DIVISOR, + max_period, + MAX_PERIOD_DIVISOR, + ); + + trace!( + "Actual Period Frame range as reported by the device: {:?} - {:?}", + min, + max + ); + } + + period_size + } + }; + + if buffer_size == ZERO_FRAMES || period_size == ZERO_FRAMES { + trace!( + "Failed to set Buffer and/or Period size, falling back to the device's defaults." + ); + + trace!("You may experience higher than normal CPU usage and/or audio issues."); + + pcm.hw_params(&hwp_clone).map_err(AlsaError::Pcm)?; + } else { + pcm.hw_params(&hwp).map_err(AlsaError::Pcm)?; + } + + let hwp = pcm.hw_params_current().map_err(AlsaError::Pcm)?; + + // Don't assume we got what we wanted. Ask to make sure. let frames_per_period = hwp.get_period_size().map_err(AlsaError::HwParams)?; let frames_per_buffer = hwp.get_buffer_size().map_err(AlsaError::HwParams)?; + let swp = pcm.sw_params_current().map_err(AlsaError::Pcm)?; + swp.set_start_threshold(frames_per_buffer - frames_per_period) .map_err(AlsaError::SwParams)?; pcm.sw_params(&swp).map_err(AlsaError::Pcm)?; + trace!("Actual Frames per Buffer: {:?}", frames_per_buffer); + trace!("Actual Frames per Period: {:?}", frames_per_period); + // Let ALSA do the math for us. pcm.frames_to_bytes(frames_per_period) as usize }; + trace!("Period Buffer size in bytes: {:?}", bytes_per_period); + Ok((pcm, bytes_per_period)) } impl Open for AlsaSink { fn open(device: Option, format: AudioFormat) -> Self { let name = match device.as_deref() { - Some("?") => match list_outputs() { + Some("?") => match list_compatible_devices() { Ok(_) => { exit(0); } - Err(err) => { - error!("Error listing Alsa outputs, {}", err); + Err(e) => { + error!("{}", e); exit(1); } }, @@ -189,38 +417,35 @@ impl Open for AlsaSink { } impl Sink for AlsaSink { - fn start(&mut self) -> io::Result<()> { + fn start(&mut self) -> SinkResult<()> { if self.pcm.is_none() { - match open_device(&self.device, self.format) { - Ok((pcm, bytes_per_period)) => { - self.pcm = Some(pcm); - self.period_buffer = Vec::with_capacity(bytes_per_period); - } - Err(e) => { - return Err(io::Error::new(io::ErrorKind::Other, e)); - } + let (pcm, bytes_per_period) = open_device(&self.device, self.format)?; + self.pcm = Some(pcm); + + if self.period_buffer.capacity() != bytes_per_period { + self.period_buffer = Vec::with_capacity(bytes_per_period); } + + // Should always match the "Period Buffer size in bytes: " trace! message. + trace!( + "Period Buffer capacity: {:?}", + self.period_buffer.capacity() + ); } Ok(()) } - fn stop(&mut self) -> io::Result<()> { - { - // Write any leftover data in the period buffer - // before draining the actual buffer - self.write_bytes(&[])?; - let pcm = self.pcm.as_mut().ok_or_else(|| { - io::Error::new(io::ErrorKind::Other, "Error stopping AlsaSink, PCM is None") - })?; - pcm.drain().map_err(|e| { - io::Error::new( - io::ErrorKind::Other, - format!("Error stopping AlsaSink {}", e), - ) - })? - } - self.pcm = None; + fn stop(&mut self) -> SinkResult<()> { + // Zero fill the remainder of the period buffer and + // write any leftover data before draining the actual PCM buffer. + self.period_buffer.resize(self.period_buffer.capacity(), 0); + self.write_buf()?; + + let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?; + + pcm.drain().map_err(AlsaError::DrainFailure)?; + Ok(()) } @@ -228,55 +453,51 @@ impl Sink for AlsaSink { } impl SinkAsBytes for AlsaSink { - fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> { - let mut processed_data = 0; - while processed_data < data.len() { - let data_to_buffer = min( - self.period_buffer.capacity() - self.period_buffer.len(), - data.len() - processed_data, - ); - self.period_buffer - .extend_from_slice(&data[processed_data..processed_data + data_to_buffer]); - processed_data += data_to_buffer; - if self.period_buffer.len() == self.period_buffer.capacity() { - self.write_buf()?; - self.period_buffer.clear(); - } - } + fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { + let mut start_index = 0; + let data_len = data.len(); + let capacity = self.period_buffer.capacity(); - Ok(()) + loop { + let data_left = data_len - start_index; + let space_left = capacity - self.period_buffer.len(); + let data_to_buffer = min(data_left, space_left); + let end_index = start_index + data_to_buffer; + + self.period_buffer + .extend_from_slice(&data[start_index..end_index]); + + if self.period_buffer.len() == capacity { + self.write_buf()?; + } + + if end_index == data_len { + break Ok(()); + } + + start_index = end_index; + } } } impl AlsaSink { pub const NAME: &'static str = "alsa"; - fn write_buf(&mut self) -> io::Result<()> { - let pcm = self.pcm.as_mut().ok_or_else(|| { - io::Error::new( - io::ErrorKind::Other, - "Error writing from AlsaSink buffer to PCM, PCM is None", - ) - })?; - let io = pcm.io_bytes(); - if let Err(err) = io.writei(&self.period_buffer) { + fn write_buf(&mut self) -> SinkResult<()> { + let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?; + + if let Err(e) = pcm.io_bytes().writei(&self.period_buffer) { // Capture and log the original error as a warning, and then try to recover. // If recovery fails then forward that error back to player. warn!( - "Error writing from AlsaSink buffer to PCM, trying to recover {}", - err + "Error writing from AlsaSink buffer to PCM, trying to recover, {}", + e ); - pcm.try_recover(err, false).map_err(|e| { - io::Error::new( - io::ErrorKind::Other, - format!( - "Error writing from AlsaSink buffer to PCM, recovery failed {}", - e - ), - ) - })? + + pcm.try_recover(e, false).map_err(AlsaError::OnWrite)? } + self.period_buffer.clear(); Ok(()) } } diff --git a/playback/src/audio_backend/gstreamer.rs b/playback/src/audio_backend/gstreamer.rs index 58f6cbc9..8b957577 100644 --- a/playback/src/audio_backend/gstreamer.rs +++ b/playback/src/audio_backend/gstreamer.rs @@ -1,4 +1,4 @@ -use super::{Open, Sink, SinkAsBytes}; +use super::{Open, Sink, SinkAsBytes, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; @@ -11,7 +11,7 @@ use gst::prelude::*; use zerocopy::AsBytes; use std::sync::mpsc::{sync_channel, SyncSender}; -use std::{io, thread}; +use std::thread; #[allow(dead_code)] pub struct GstreamerSink { @@ -131,7 +131,7 @@ impl Sink for GstreamerSink { } impl SinkAsBytes for GstreamerSink { - fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> { + fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { // Copy expensively (in to_vec()) to avoid thread synchronization self.tx .send(data.to_vec()) diff --git a/playback/src/audio_backend/jackaudio.rs b/playback/src/audio_backend/jackaudio.rs index f55f20a8..15acf99d 100644 --- a/playback/src/audio_backend/jackaudio.rs +++ b/playback/src/audio_backend/jackaudio.rs @@ -1,4 +1,4 @@ -use super::{Open, Sink}; +use super::{Open, Sink, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; @@ -6,7 +6,6 @@ use crate::NUM_CHANNELS; use jack::{ AsyncClient, AudioOut, Client, ClientOptions, Control, Port, ProcessHandler, ProcessScope, }; -use std::io; use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; pub struct JackSink { @@ -25,15 +24,12 @@ pub struct JackData { impl ProcessHandler for JackData { fn process(&mut self, _: &Client, ps: &ProcessScope) -> Control { // get output port buffers - let mut out_r = self.port_r.as_mut_slice(ps); - let mut out_l = self.port_l.as_mut_slice(ps); - let buf_r: &mut [f32] = &mut out_r; - let buf_l: &mut [f32] = &mut out_l; + let buf_r: &mut [f32] = self.port_r.as_mut_slice(ps); + let buf_l: &mut [f32] = self.port_l.as_mut_slice(ps); // get queue iterator let mut queue_iter = self.rec.try_iter(); - let buf_size = buf_r.len(); - for i in 0..buf_size { + for i in 0..buf_r.len() { buf_r[i] = queue_iter.next().unwrap_or(0.0); buf_l[i] = queue_iter.next().unwrap_or(0.0); } @@ -70,8 +66,12 @@ impl Open for JackSink { } impl Sink for JackSink { - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { - let samples_f32: &[f32] = &converter.f64_to_f32(packet.samples()); + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { + let samples = packet + .samples() + .map_err(|e| SinkError::OnWrite(e.to_string()))?; + + let samples_f32: &[f32] = &converter.f64_to_f32(samples); for sample in samples_f32.iter() { let res = self.send.send(*sample); if res.is_err() { diff --git a/playback/src/audio_backend/mod.rs b/playback/src/audio_backend/mod.rs index 31fb847c..dc21fb3d 100644 --- a/playback/src/audio_backend/mod.rs +++ b/playback/src/audio_backend/mod.rs @@ -1,26 +1,40 @@ use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use std::io; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SinkError { + #[error("Audio Sink Error Not Connected: {0}")] + NotConnected(String), + #[error("Audio Sink Error Connection Refused: {0}")] + ConnectionRefused(String), + #[error("Audio Sink Error On Write: {0}")] + OnWrite(String), + #[error("Audio Sink Error Invalid Parameters: {0}")] + InvalidParams(String), +} + +pub type SinkResult = Result; pub trait Open { fn open(_: Option, format: AudioFormat) -> Self; } pub trait Sink { - fn start(&mut self) -> io::Result<()> { + fn start(&mut self) -> SinkResult<()> { Ok(()) } - fn stop(&mut self) -> io::Result<()> { + fn stop(&mut self) -> SinkResult<()> { Ok(()) } - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()>; + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()>; } pub type SinkBuilder = fn(Option, AudioFormat) -> Box; pub trait SinkAsBytes { - fn write_bytes(&mut self, data: &[u8]) -> io::Result<()>; + fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()>; } fn mk_sink(device: Option, format: AudioFormat) -> Box { @@ -30,7 +44,7 @@ fn mk_sink(device: Option, format: AudioFormat // reuse code for various backends macro_rules! sink_as_bytes { () => { - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { use crate::convert::i24; use zerocopy::AsBytes; match packet { @@ -90,7 +104,7 @@ use self::gstreamer::GstreamerSink; #[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))] mod rodio; -#[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))] +#[cfg(feature = "rodio-backend")] use self::rodio::RodioSink; #[cfg(feature = "sdl-backend")] @@ -132,11 +146,6 @@ pub fn find(name: Option) -> Option { .find(|backend| name == backend.0) .map(|backend| backend.1) } else { - Some( - BACKENDS - .first() - .expect("No backends were enabled at build time") - .1, - ) + BACKENDS.first().map(|backend| backend.1) } } diff --git a/playback/src/audio_backend/pipe.rs b/playback/src/audio_backend/pipe.rs index 56040384..fd804a0e 100644 --- a/playback/src/audio_backend/pipe.rs +++ b/playback/src/audio_backend/pipe.rs @@ -1,4 +1,4 @@ -use super::{Open, Sink, SinkAsBytes}; +use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; @@ -23,14 +23,14 @@ impl Open for StdoutSink { } impl Sink for StdoutSink { - fn start(&mut self) -> io::Result<()> { + fn start(&mut self) -> SinkResult<()> { if self.output.is_none() { let output: Box = match self.path.as_deref() { Some(path) => { let open_op = OpenOptions::new() .write(true) .open(path) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(|e| SinkError::ConnectionRefused(e.to_string()))?; Box::new(open_op) } None => Box::new(io::stdout()), @@ -46,14 +46,18 @@ impl Sink for StdoutSink { } impl SinkAsBytes for StdoutSink { - fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> { + fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { match self.output.as_deref_mut() { Some(output) => { - output.write_all(data)?; - output.flush()?; + output + .write_all(data) + .map_err(|e| SinkError::OnWrite(e.to_string()))?; + output + .flush() + .map_err(|e| SinkError::OnWrite(e.to_string()))?; } None => { - return Err(io::Error::new(io::ErrorKind::Other, "Output is None")); + return Err(SinkError::NotConnected("Output is None".to_string())); } } diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index 378deb48..7a0b179f 100644 --- a/playback/src/audio_backend/portaudio.rs +++ b/playback/src/audio_backend/portaudio.rs @@ -1,11 +1,10 @@ -use super::{Open, Sink}; +use super::{Open, Sink, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::{NUM_CHANNELS, SAMPLE_RATE}; use portaudio_rs::device::{get_default_output_index, DeviceIndex, DeviceInfo}; use portaudio_rs::stream::*; -use std::io; use std::process::exit; use std::time::Duration; @@ -96,7 +95,7 @@ impl<'a> Open for PortAudioSink<'a> { } impl<'a> Sink for PortAudioSink<'a> { - fn start(&mut self) -> io::Result<()> { + fn start(&mut self) -> SinkResult<()> { macro_rules! start_sink { (ref mut $stream: ident, ref $parameters: ident) => {{ if $stream.is_none() { @@ -125,7 +124,7 @@ impl<'a> Sink for PortAudioSink<'a> { Ok(()) } - fn stop(&mut self) -> io::Result<()> { + fn stop(&mut self) -> SinkResult<()> { macro_rules! stop_sink { (ref mut $stream: ident) => {{ $stream.as_mut().unwrap().stop().unwrap(); @@ -141,14 +140,17 @@ impl<'a> Sink for PortAudioSink<'a> { Ok(()) } - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { macro_rules! write_sink { (ref mut $stream: expr, $samples: expr) => { $stream.as_mut().unwrap().write($samples) }; } - let samples = packet.samples(); + let samples = packet + .samples() + .map_err(|e| SinkError::OnWrite(e.to_string()))?; + let result = match self { Self::F32(stream, _parameters) => { let samples_f32: &[f32] = &converter.f64_to_f32(samples); diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs index e36941ea..7487517f 100644 --- a/playback/src/audio_backend/pulseaudio.rs +++ b/playback/src/audio_backend/pulseaudio.rs @@ -1,84 +1,129 @@ -use super::{Open, Sink, SinkAsBytes}; +use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::{NUM_CHANNELS, SAMPLE_RATE}; -use libpulse_binding::{self as pulse, stream::Direction}; +use libpulse_binding::{self as pulse, error::PAErr, stream::Direction}; use libpulse_simple_binding::Simple; -use std::io; +use thiserror::Error; const APP_NAME: &str = "librespot"; const STREAM_NAME: &str = "Spotify endpoint"; +#[derive(Debug, Error)] +enum PulseError { + #[error(" Unsupported Pulseaudio Sample Spec, Format {pulse_format:?} ({format:?}), Channels {channels}, Rate {rate}")] + InvalidSampleSpec { + pulse_format: pulse::sample::Format, + format: AudioFormat, + channels: u8, + rate: u32, + }, + + #[error(" {0}")] + ConnectionRefused(PAErr), + + #[error(" Failed to Drain Pulseaudio Buffer, {0}")] + DrainFailure(PAErr), + + #[error("")] + NotConnected, + + #[error(" {0}")] + OnWrite(PAErr), +} + +impl From for SinkError { + fn from(e: PulseError) -> SinkError { + use PulseError::*; + let es = e.to_string(); + match e { + DrainFailure(_) | OnWrite(_) => SinkError::OnWrite(es), + ConnectionRefused(_) => SinkError::ConnectionRefused(es), + NotConnected => SinkError::NotConnected(es), + InvalidSampleSpec { .. } => SinkError::InvalidParams(es), + } + } +} + pub struct PulseAudioSink { s: Option, - ss: pulse::sample::Spec, device: Option, format: AudioFormat, } impl Open for PulseAudioSink { fn open(device: Option, format: AudioFormat) -> Self { - info!("Using PulseAudio sink with format: {:?}", format); + let mut actual_format = format; - // PulseAudio calls S24 and S24_3 different from the rest of the world - let pulse_format = match format { - AudioFormat::F32 => pulse::sample::Format::FLOAT32NE, - AudioFormat::S32 => pulse::sample::Format::S32NE, - AudioFormat::S24 => pulse::sample::Format::S24_32NE, - AudioFormat::S24_3 => pulse::sample::Format::S24NE, - AudioFormat::S16 => pulse::sample::Format::S16NE, - _ => { - unimplemented!("PulseAudio currently does not support {:?} output", format) - } - }; + if actual_format == AudioFormat::F64 { + warn!("PulseAudio currently does not support F64 output"); + actual_format = AudioFormat::F32; + } - let ss = pulse::sample::Spec { - format: pulse_format, - channels: NUM_CHANNELS, - rate: SAMPLE_RATE, - }; - debug_assert!(ss.is_valid()); + info!("Using PulseAudioSink with format: {:?}", actual_format); Self { s: None, - ss, device, - format, + format: actual_format, } } } impl Sink for PulseAudioSink { - fn start(&mut self) -> io::Result<()> { - if self.s.is_some() { - return Ok(()); + fn start(&mut self) -> SinkResult<()> { + if self.s.is_none() { + // PulseAudio calls S24 and S24_3 different from the rest of the world + let pulse_format = match self.format { + AudioFormat::F32 => pulse::sample::Format::FLOAT32NE, + AudioFormat::S32 => pulse::sample::Format::S32NE, + AudioFormat::S24 => pulse::sample::Format::S24_32NE, + AudioFormat::S24_3 => pulse::sample::Format::S24NE, + AudioFormat::S16 => pulse::sample::Format::S16NE, + _ => unreachable!(), + }; + + let ss = pulse::sample::Spec { + format: pulse_format, + channels: NUM_CHANNELS, + rate: SAMPLE_RATE, + }; + + if !ss.is_valid() { + let pulse_error = PulseError::InvalidSampleSpec { + pulse_format, + format: self.format, + channels: NUM_CHANNELS, + rate: SAMPLE_RATE, + }; + + return Err(SinkError::from(pulse_error)); + } + + let s = Simple::new( + None, // Use the default server. + APP_NAME, // Our application's name. + Direction::Playback, // Direction. + self.device.as_deref(), // Our device (sink) name. + STREAM_NAME, // Description of our stream. + &ss, // Our sample format. + None, // Use default channel map. + None, // Use default buffering attributes. + ) + .map_err(PulseError::ConnectionRefused)?; + + self.s = Some(s); } - let device = self.device.as_deref(); - let result = Simple::new( - None, // Use the default server. - APP_NAME, // Our application's name. - Direction::Playback, // Direction. - device, // Our device (sink) name. - STREAM_NAME, // Description of our stream. - &self.ss, // Our sample format. - None, // Use default channel map. - None, // Use default buffering attributes. - ); - match result { - Ok(s) => { - self.s = Some(s); - Ok(()) - } - Err(e) => Err(io::Error::new( - io::ErrorKind::ConnectionRefused, - e.to_string().unwrap(), - )), - } + Ok(()) } - fn stop(&mut self) -> io::Result<()> { + fn stop(&mut self) -> SinkResult<()> { + let s = self.s.as_mut().ok_or(PulseError::NotConnected)?; + + s.drain().map_err(PulseError::DrainFailure)?; + self.s = None; Ok(()) } @@ -87,21 +132,12 @@ impl Sink for PulseAudioSink { } impl SinkAsBytes for PulseAudioSink { - fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> { - if let Some(s) = &self.s { - match s.write(data) { - Ok(_) => Ok(()), - Err(e) => Err(io::Error::new( - io::ErrorKind::BrokenPipe, - e.to_string().unwrap(), - )), - } - } else { - Err(io::Error::new( - io::ErrorKind::NotConnected, - "Not connected to PulseAudio", - )) - } + fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { + let s = self.s.as_mut().ok_or(PulseError::NotConnected)?; + + s.write(data).map_err(PulseError::OnWrite)?; + + Ok(()) } } diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index 1e999938..ab356d67 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -1,11 +1,11 @@ use std::process::exit; +use std::thread; use std::time::Duration; -use std::{io, thread}; use cpal::traits::{DeviceTrait, HostTrait}; use thiserror::Error; -use super::Sink; +use super::{Sink, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; @@ -33,16 +33,30 @@ pub fn mk_rodiojack(device: Option, format: AudioFormat) -> Box No Device Available")] NoDeviceAvailable, - #[error("Rodio: device \"{0}\" is not available")] + #[error(" device \"{0}\" is Not Available")] DeviceNotAvailable(String), - #[error("Rodio play error: {0}")] + #[error(" Play Error: {0}")] PlayError(#[from] rodio::PlayError), - #[error("Rodio stream error: {0}")] + #[error(" Stream Error: {0}")] StreamError(#[from] rodio::StreamError), - #[error("Cannot get audio devices: {0}")] + #[error(" Cannot Get Audio Devices: {0}")] DevicesError(#[from] cpal::DevicesError), + #[error(" {0}")] + Samples(String), +} + +impl From for SinkError { + fn from(e: RodioError) -> SinkError { + use RodioError::*; + let es = e.to_string(); + match e { + StreamError(_) | PlayError(_) | Samples(_) => SinkError::OnWrite(es), + NoDeviceAvailable | DeviceNotAvailable(_) => SinkError::ConnectionRefused(es), + DevicesError(_) => SinkError::InvalidParams(es), + } + } } pub struct RodioSink { @@ -175,8 +189,10 @@ pub fn open(host: cpal::Host, device: Option, format: AudioFormat) -> Ro } impl Sink for RodioSink { - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { - let samples = packet.samples(); + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { + let samples = packet + .samples() + .map_err(|e| RodioError::Samples(e.to_string()))?; match self.format { AudioFormat::F32 => { let samples_f32: &[f32] = &converter.f64_to_f32(samples); @@ -211,5 +227,6 @@ impl Sink for RodioSink { } impl RodioSink { + #[allow(dead_code)] pub const NAME: &'static str = "rodio"; } diff --git a/playback/src/audio_backend/sdl.rs b/playback/src/audio_backend/sdl.rs index 28d140e8..6272fa32 100644 --- a/playback/src/audio_backend/sdl.rs +++ b/playback/src/audio_backend/sdl.rs @@ -1,11 +1,11 @@ -use super::{Open, Sink}; +use super::{Open, Sink, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::{NUM_CHANNELS, SAMPLE_RATE}; use sdl2::audio::{AudioQueue, AudioSpecDesired}; +use std::thread; use std::time::Duration; -use std::{io, thread}; pub enum SdlSink { F32(AudioQueue), @@ -52,7 +52,7 @@ impl Open for SdlSink { } impl Sink for SdlSink { - fn start(&mut self) -> io::Result<()> { + fn start(&mut self) -> SinkResult<()> { macro_rules! start_sink { ($queue: expr) => {{ $queue.clear(); @@ -67,7 +67,7 @@ impl Sink for SdlSink { Ok(()) } - fn stop(&mut self) -> io::Result<()> { + fn stop(&mut self) -> SinkResult<()> { macro_rules! stop_sink { ($queue: expr) => {{ $queue.pause(); @@ -82,7 +82,7 @@ impl Sink for SdlSink { Ok(()) } - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { macro_rules! drain_sink { ($queue: expr, $size: expr) => {{ // sleep and wait for sdl thread to drain the queue a bit @@ -92,7 +92,9 @@ impl Sink for SdlSink { }}; } - let samples = packet.samples(); + let samples = packet + .samples() + .map_err(|e| SinkError::OnWrite(e.to_string()))?; match self { Self::F32(queue) => { let samples_f32: &[f32] = &converter.f64_to_f32(samples); diff --git a/playback/src/audio_backend/subprocess.rs b/playback/src/audio_backend/subprocess.rs index 64f04c88..c501cf83 100644 --- a/playback/src/audio_backend/subprocess.rs +++ b/playback/src/audio_backend/subprocess.rs @@ -1,10 +1,10 @@ -use super::{Open, Sink, SinkAsBytes}; +use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; use shell_words::split; -use std::io::{self, Write}; +use std::io::Write; use std::process::{Child, Command, Stdio}; pub struct SubprocessSink { @@ -30,21 +30,25 @@ impl Open for SubprocessSink { } impl Sink for SubprocessSink { - fn start(&mut self) -> io::Result<()> { + fn start(&mut self) -> SinkResult<()> { let args = split(&self.shell_command).unwrap(); - self.child = Some( - Command::new(&args[0]) - .args(&args[1..]) - .stdin(Stdio::piped()) - .spawn()?, - ); + let child = Command::new(&args[0]) + .args(&args[1..]) + .stdin(Stdio::piped()) + .spawn() + .map_err(|e| SinkError::ConnectionRefused(e.to_string()))?; + self.child = Some(child); Ok(()) } - fn stop(&mut self) -> io::Result<()> { + fn stop(&mut self) -> SinkResult<()> { if let Some(child) = &mut self.child.take() { - child.kill()?; - child.wait()?; + child + .kill() + .map_err(|e| SinkError::OnWrite(e.to_string()))?; + child + .wait() + .map_err(|e| SinkError::OnWrite(e.to_string()))?; } Ok(()) } @@ -53,11 +57,18 @@ impl Sink for SubprocessSink { } impl SinkAsBytes for SubprocessSink { - fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> { + fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { if let Some(child) = &mut self.child { - let child_stdin = child.stdin.as_mut().unwrap(); - child_stdin.write_all(data)?; - child_stdin.flush()?; + let child_stdin = child + .stdin + .as_mut() + .ok_or_else(|| SinkError::NotConnected("Child is None".to_string()))?; + child_stdin + .write_all(data) + .map_err(|e| SinkError::OnWrite(e.to_string()))?; + child_stdin + .flush() + .map_err(|e| SinkError::OnWrite(e.to_string()))?; } Ok(()) } diff --git a/playback/src/config.rs b/playback/src/config.rs index 7604f59f..b8313bf4 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -76,10 +76,11 @@ impl AudioFormat { } } -#[derive(Clone, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub enum NormalisationType { Album, Track, + Auto, } impl FromStr for NormalisationType { @@ -88,6 +89,7 @@ impl FromStr for NormalisationType { match s.to_lowercase().as_ref() { "album" => Ok(Self::Album), "track" => Ok(Self::Track), + "auto" => Ok(Self::Auto), _ => Err(()), } } @@ -95,11 +97,11 @@ impl FromStr for NormalisationType { impl Default for NormalisationType { fn default() -> Self { - Self::Album + Self::Auto } } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub enum NormalisationMethod { Basic, Dynamic, @@ -151,7 +153,7 @@ impl Default for PlayerConfig { normalisation_type: NormalisationType::default(), normalisation_method: NormalisationMethod::default(), normalisation_pregain: 0.0, - normalisation_threshold: db_to_ratio(-1.0), + normalisation_threshold: db_to_ratio(-2.0), normalisation_attack: Duration::from_millis(5), normalisation_release: Duration::from_millis(100), normalisation_knee: 1.0, diff --git a/playback/src/decoder/lewton_decoder.rs b/playback/src/decoder/lewton_decoder.rs index adf63e2a..bc90b992 100644 --- a/playback/src/decoder/lewton_decoder.rs +++ b/playback/src/decoder/lewton_decoder.rs @@ -1,22 +1,23 @@ -use super::{AudioDecoder, AudioError, AudioPacket}; +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::error; -use std::fmt; use std::io::{Read, Seek}; -use std::time::Duration; pub struct VorbisDecoder(OggStreamReader); -pub struct VorbisError(lewton::VorbisError); impl VorbisDecoder where R: Read + Seek, { - pub fn new(input: R) -> Result, VorbisError> { - Ok(VorbisDecoder(OggStreamReader::new(input)?)) + pub fn new(input: R) -> DecoderResult> { + let reader = + OggStreamReader::new(input).map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?; + Ok(VorbisDecoder(reader)) } } @@ -24,51 +25,22 @@ impl AudioDecoder for VorbisDecoder where R: Read + Seek, { - fn seek(&mut self, ms: i64) -> Result<(), AudioError> { - let absgp = Duration::from_millis(ms as u64 * crate::SAMPLE_RATE as u64).as_secs(); - match self.0.seek_absgp_pg(absgp as u64) { - Ok(_) => Ok(()), - Err(err) => Err(AudioError::VorbisError(err.into())), - } + 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) -> Result, AudioError> { - use lewton::audio::AudioReadError::AudioIsHeader; - use lewton::OggReadError::NoCapturePatternFound; - use lewton::VorbisError::{BadAudio, OggError}; + 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(err) => return Err(AudioError::VorbisError(err.into())), + Err(e) => return Err(DecoderError::LewtonDecoder(e.to_string())), } } } } - -impl From for VorbisError { - fn from(err: lewton::VorbisError) -> VorbisError { - VorbisError(err) - } -} - -impl fmt::Debug for VorbisError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Debug::fmt(&self.0, f) - } -} - -impl fmt::Display for VorbisError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Display::fmt(&self.0, f) - } -} - -impl error::Error for VorbisError { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - error::Error::source(&self.0) - } -} diff --git a/playback/src/decoder/mod.rs b/playback/src/decoder/mod.rs index 9641e8b3..087bba4c 100644 --- a/playback/src/decoder/mod.rs +++ b/playback/src/decoder/mod.rs @@ -1,10 +1,30 @@ -use std::fmt; +use thiserror::Error; mod lewton_decoder; -pub use lewton_decoder::{VorbisDecoder, VorbisError}; +pub use lewton_decoder::VorbisDecoder; mod passthrough_decoder; -pub use passthrough_decoder::{PassthroughDecoder, PassthroughError}; +pub use passthrough_decoder::PassthroughDecoder; + +#[derive(Error, Debug)] +pub enum DecoderError { + #[error("Lewton Decoder Error: {0}")] + LewtonDecoder(String), + #[error("Passthrough Decoder Error: {0}")] + PassthroughDecoder(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")] + Samples, +} + +pub type AudioPacketResult = Result; pub enum AudioPacket { Samples(Vec), @@ -17,17 +37,17 @@ impl AudioPacket { AudioPacket::Samples(f64_samples) } - pub fn samples(&self) -> &[f64] { + pub fn samples(&self) -> AudioPacketResult<&[f64]> { match self { - AudioPacket::Samples(s) => s, - AudioPacket::OggData(_) => panic!("can't return OggData on samples"), + AudioPacket::Samples(s) => Ok(s), + AudioPacket::OggData(_) => Err(AudioPacketError::OggData), } } - pub fn oggdata(&self) -> &[u8] { + pub fn oggdata(&self) -> AudioPacketResult<&[u8]> { match self { - AudioPacket::Samples(_) => panic!("can't return samples on OggData"), - AudioPacket::OggData(d) => d, + AudioPacket::OggData(d) => Ok(d), + AudioPacket::Samples(_) => Err(AudioPacketError::Samples), } } @@ -39,34 +59,7 @@ impl AudioPacket { } } -#[derive(Debug)] -pub enum AudioError { - PassthroughError(PassthroughError), - VorbisError(VorbisError), -} - -impl fmt::Display for AudioError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - AudioError::PassthroughError(err) => write!(f, "PassthroughError({})", err), - AudioError::VorbisError(err) => write!(f, "VorbisError({})", err), - } - } -} - -impl From for AudioError { - fn from(err: VorbisError) -> AudioError { - AudioError::VorbisError(err) - } -} - -impl From for AudioError { - fn from(err: PassthroughError) -> AudioError { - AudioError::PassthroughError(err) - } -} - pub trait AudioDecoder { - fn seek(&mut self, ms: i64) -> Result<(), AudioError>; - fn next_packet(&mut self) -> Result, AudioError>; + fn seek(&mut self, absgp: u64) -> DecoderResult<()>; + fn next_packet(&mut self) -> DecoderResult>; } diff --git a/playback/src/decoder/passthrough_decoder.rs b/playback/src/decoder/passthrough_decoder.rs index 7c1ad532..dd8e3b32 100644 --- a/playback/src/decoder/passthrough_decoder.rs +++ b/playback/src/decoder/passthrough_decoder.rs @@ -1,23 +1,22 @@ // Passthrough decoder for librespot -use super::{AudioDecoder, AudioError, AudioPacket}; -use crate::SAMPLE_RATE; +use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult}; use ogg::{OggReadError, Packet, PacketReader, PacketWriteEndInfo, PacketWriter}; -use std::fmt; use std::io::{Read, Seek}; -use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; -fn get_header(code: u8, rdr: &mut PacketReader) -> Result, PassthroughError> +fn get_header(code: u8, rdr: &mut PacketReader) -> DecoderResult> where T: Read + Seek, { - let pck: Packet = rdr.read_packet_expected()?; + let pck: Packet = rdr + .read_packet_expected() + .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; let pkt_type = pck.data[0]; debug!("Vorbis header type {}", &pkt_type); if pkt_type != code { - return Err(PassthroughError(OggReadError::InvalidData)); + return Err(DecoderError::PassthroughDecoder("Invalid Data".to_string())); } Ok(pck.data.into_boxed_slice()) @@ -35,16 +34,14 @@ pub struct PassthroughDecoder { setup: Box<[u8]>, } -pub struct PassthroughError(ogg::OggReadError); - impl PassthroughDecoder { /// Constructs a new Decoder from a given implementation of `Read + Seek`. - pub fn new(rdr: R) -> Result { + pub fn new(rdr: R) -> DecoderResult { let mut rdr = PacketReader::new(rdr); - let stream_serial = SystemTime::now() + let since_epoch = SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as u32; + .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; + let stream_serial = since_epoch.as_millis() as u32; info!("Starting passthrough track with serial {}", stream_serial); @@ -71,9 +68,7 @@ impl PassthroughDecoder { } impl AudioDecoder for PassthroughDecoder { - fn seek(&mut self, ms: i64) -> Result<(), AudioError> { - info!("Seeking to {}", ms); - + fn seek(&mut self, absgp: u64) -> DecoderResult<()> { // add an eos to previous stream if missing if self.bos && !self.eos { match self.rdr.read_packet() { @@ -86,7 +81,7 @@ impl AudioDecoder for PassthroughDecoder { PacketWriteEndInfo::EndStream, absgp_page, ) - .unwrap(); + .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; } _ => warn! {"Cannot write EoS after seeking"}, }; @@ -97,23 +92,29 @@ impl AudioDecoder for PassthroughDecoder { self.ofsgp_page = 0; self.stream_serial += 1; - // hard-coded to 44.1 kHz - match self.rdr.seek_absgp( - None, - Duration::from_millis(ms as u64 * SAMPLE_RATE as u64).as_secs(), - ) { + match self.rdr.seek_absgp(None, absgp) { Ok(_) => { // need to set some offset for next_page() - let pck = self.rdr.read_packet().unwrap().unwrap(); - self.ofsgp_page = pck.absgp_page(); - debug!("Seek to offset page {}", self.ofsgp_page); - Ok(()) + let pck = self + .rdr + .read_packet() + .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(()) + } + None => Err(DecoderError::PassthroughDecoder( + "Packet is None".to_string(), + )), + } } - Err(err) => Err(AudioError::PassthroughError(err.into())), + Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())), } } - fn next_packet(&mut self) -> Result, AudioError> { + fn next_packet(&mut self) -> DecoderResult> { // write headers if we are (re)starting if !self.bos { self.wtr @@ -123,7 +124,7 @@ impl AudioDecoder for PassthroughDecoder { PacketWriteEndInfo::EndPage, 0, ) - .unwrap(); + .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; self.wtr .write_packet( self.comment.clone(), @@ -131,7 +132,7 @@ impl AudioDecoder for PassthroughDecoder { PacketWriteEndInfo::NormalPacket, 0, ) - .unwrap(); + .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; self.wtr .write_packet( self.setup.clone(), @@ -139,7 +140,7 @@ impl AudioDecoder for PassthroughDecoder { PacketWriteEndInfo::EndPage, 0, ) - .unwrap(); + .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; self.bos = true; debug!("Wrote Ogg headers"); } @@ -151,7 +152,7 @@ impl AudioDecoder for PassthroughDecoder { info!("end of streaming"); return Ok(None); } - Err(err) => return Err(AudioError::PassthroughError(err.into())), + Err(e) => return Err(DecoderError::PassthroughDecoder(e.to_string())), }; let pckgp_page = pck.absgp_page(); @@ -178,32 +179,14 @@ impl AudioDecoder for PassthroughDecoder { inf, pckgp_page - self.ofsgp_page, ) - .unwrap(); + .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; let data = self.wtr.inner_mut(); if !data.is_empty() { - let result = AudioPacket::OggData(std::mem::take(data)); - return Ok(Some(result)); + let ogg_data = AudioPacket::OggData(std::mem::take(data)); + return Ok(Some(ogg_data)); } } } } - -impl fmt::Debug for PassthroughError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Debug::fmt(&self.0, f) - } -} - -impl From for PassthroughError { - fn from(err: OggReadError) -> PassthroughError { - PassthroughError(err) - } -} - -impl fmt::Display for PassthroughError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Display::fmt(&self.0, f) - } -} diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs new file mode 100644 index 00000000..309c495d --- /dev/null +++ b/playback/src/decoder/symphonia_decoder.rs @@ -0,0 +1,136 @@ +use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult}; + +use crate::audio::AudioFile; + +use symphonia::core::audio::{AudioBufferRef, Channels}; +use symphonia::core::codecs::Decoder; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::{FormatReader, SeekMode, SeekTo}; +use symphonia::core::io::{MediaSource, MediaSourceStream}; +use symphonia::core::units::TimeStamp; +use symphonia::default::{codecs::VorbisDecoder, formats::OggReader}; + +use std::io::{Read, Seek, SeekFrom}; + +impl MediaSource for FileWithConstSize +where + R: Read + Seek + Send, +{ + fn is_seekable(&self) -> bool { + true + } + + fn byte_len(&self) -> Option { + Some(self.len()) + } +} + +pub struct FileWithConstSize { + stream: T, + len: u64, +} + +impl FileWithConstSize { + pub fn len(&self) -> u64 { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl FileWithConstSize +where + T: Seek, +{ + pub fn new(mut stream: T) -> Self { + stream.seek(SeekFrom::End(0)).unwrap(); + let len = stream.stream_position().unwrap(); + stream.seek(SeekFrom::Start(0)).unwrap(); + Self { stream, len } + } +} + +impl Read for FileWithConstSize +where + T: Read, +{ + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.stream.read(buf) + } +} + +impl Seek for FileWithConstSize +where + T: Seek, +{ + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + self.stream.seek(pos) + } +} + +pub struct SymphoniaDecoder { + track_id: u32, + decoder: Box, + format: Box, + position: TimeStamp, +} + +impl SymphoniaDecoder { + pub fn new(input: R) -> DecoderResult + where + R: Read + Seek, + { + let mss_opts = Default::default(); + let mss = MediaSourceStream::new(Box::new(FileWithConstSize::new(input)), mss_opts); + + let format_opts = Default::default(); + let format = OggReader::try_new(mss, &format_opts).map_err(|e| DecoderError::SymphoniaDecoder(e.to_string()))?; + + let track = format.default_track().unwrap(); + let decoder_opts = Default::default(); + let decoder = VorbisDecoder::try_new(&track.codec_params, &decoder_opts)?; + + Ok(Self { + track_id: track.id, + decoder: Box::new(decoder), + format: Box::new(format), + position: 0, + }) + } +} + +impl AudioDecoder for SymphoniaDecoder { + fn seek(&mut self, absgp: u64) -> DecoderResult<()> { + let seeked_to = self.format.seek( + SeekMode::Accurate, + SeekTo::Time { + time: absgp, // TODO : move to Duration + track_id: Some(self.track_id), + }, + )?; + self.position = seeked_to.actual_ts; + // TODO : Ok(self.position) + Ok(()) + } + + fn next_packet(&mut self) -> DecoderResult> { + let packet = match self.format.next_packet() { + Ok(packet) => packet, + Err(e) => { + log::error!("format error: {}", err); + return Err(DecoderError::SymphoniaDecoder(e.to_string())), + } + }; + match self.decoder.decode(&packet) { + Ok(audio_buf) => { + self.position += packet.frames() as TimeStamp; + Ok(Some(packet)) + } + // TODO: Handle non-fatal decoding errors and retry. + Err(e) => + return Err(DecoderError::SymphoniaDecoder(e.to_string())), + } + } +} diff --git a/playback/src/dither.rs b/playback/src/dither.rs index 2510b886..0f667917 100644 --- a/playback/src/dither.rs +++ b/playback/src/dither.rs @@ -1,4 +1,5 @@ -use rand::rngs::ThreadRng; +use rand::rngs::SmallRng; +use rand::SeedableRng; use rand_distr::{Distribution, Normal, Triangular, Uniform}; use std::fmt; @@ -41,20 +42,19 @@ impl fmt::Display for dyn Ditherer { } } -// Implementation note: we save the handle to ThreadRng so it doesn't require -// a lookup on each call (which is on each sample!). This is ~2.5x as fast. -// Downside is that it is not Send so we cannot move it around player threads. -// +fn create_rng() -> SmallRng { + SmallRng::from_entropy() +} pub struct TriangularDitherer { - cached_rng: ThreadRng, + cached_rng: SmallRng, distribution: Triangular, } impl Ditherer for TriangularDitherer { fn new() -> Self { Self { - cached_rng: rand::thread_rng(), + cached_rng: create_rng(), // 2 LSB peak-to-peak needed to linearize the response: distribution: Triangular::new(-1.0, 1.0, 0.0).unwrap(), } @@ -74,14 +74,14 @@ impl TriangularDitherer { } pub struct GaussianDitherer { - cached_rng: ThreadRng, + cached_rng: SmallRng, distribution: Normal, } impl Ditherer for GaussianDitherer { fn new() -> Self { Self { - cached_rng: rand::thread_rng(), + cached_rng: create_rng(), // 1/2 LSB RMS needed to linearize the response: distribution: Normal::new(0.0, 0.5).unwrap(), } @@ -103,7 +103,7 @@ impl GaussianDitherer { pub struct HighPassDitherer { active_channel: usize, previous_noises: [f64; NUM_CHANNELS], - cached_rng: ThreadRng, + cached_rng: SmallRng, distribution: Uniform, } @@ -112,7 +112,7 @@ impl Ditherer for HighPassDitherer { Self { active_channel: 0, previous_noises: [0.0; NUM_CHANNELS], - cached_rng: rand::thread_rng(), + cached_rng: create_rng(), distribution: Uniform::new_inclusive(-0.5, 0.5), // 1 LSB +/- 1 LSB (previous) = 2 LSB } } diff --git a/playback/src/lib.rs b/playback/src/lib.rs index 689b8470..a52ca2fa 100644 --- a/playback/src/lib.rs +++ b/playback/src/lib.rs @@ -7,8 +7,8 @@ use librespot_metadata as metadata; pub mod audio_backend; pub mod config; -mod convert; -mod decoder; +pub mod convert; +pub mod decoder; pub mod dither; pub mod mixer; pub mod player; @@ -16,3 +16,5 @@ pub mod player; pub const SAMPLE_RATE: u32 = 44100; pub const NUM_CHANNELS: u8 = 2; pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32; +pub const PAGES_PER_MS: f64 = SAMPLE_RATE as f64 / 1000.0; +pub const MS_PER_PAGE: f64 = 1000.0 / SAMPLE_RATE as f64; diff --git a/playback/src/mixer/alsamixer.rs b/playback/src/mixer/alsamixer.rs index 8bee9e0d..55398cb7 100644 --- a/playback/src/mixer/alsamixer.rs +++ b/playback/src/mixer/alsamixer.rs @@ -10,6 +10,7 @@ use alsa::{Ctl, Round}; use std::ffi::CString; #[derive(Clone)] +#[allow(dead_code)] pub struct AlsaMixer { config: MixerConfig, min: i64, @@ -31,14 +32,14 @@ const ZERO_DB: MilliBel = MilliBel(0); impl Mixer for AlsaMixer { fn open(config: MixerConfig) -> Self { info!( - "Mixing with alsa and volume control: {:?} for card: {} with mixer control: {},{}", - config.volume_ctrl, config.card, config.control, config.index, + "Mixing with Alsa and volume control: {:?} for device: {} with mixer control: {},{}", + config.volume_ctrl, config.device, config.control, config.index, ); let mut config = config; // clone let mixer = - alsa::mixer::Mixer::new(&config.card, false).expect("Could not open Alsa mixer"); + alsa::mixer::Mixer::new(&config.device, false).expect("Could not open Alsa mixer"); let simple_element = mixer .find_selem(&SelemId::new(&config.control, config.index)) .expect("Could not find Alsa mixer control"); @@ -56,8 +57,8 @@ impl Mixer for AlsaMixer { // Query dB volume range -- note that Alsa exposes a different // API for hardware and software mixers let (min_millibel, max_millibel) = if is_softvol { - let control = - Ctl::new(&config.card, false).expect("Could not open Alsa softvol with that card"); + let control = Ctl::new(&config.device, false) + .expect("Could not open Alsa softvol with that device"); let mut element_id = ElemId::new(ElemIface::Mixer); element_id.set_name( &CString::new(config.control.as_str()) @@ -144,7 +145,7 @@ impl Mixer for AlsaMixer { fn volume(&self) -> u16 { let mixer = - alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer"); + alsa::mixer::Mixer::new(&self.config.device, false).expect("Could not open Alsa mixer"); let simple_element = mixer .find_selem(&SelemId::new(&self.config.control, self.config.index)) .expect("Could not find Alsa mixer control"); @@ -184,7 +185,7 @@ impl Mixer for AlsaMixer { fn set_volume(&self, volume: u16) { let mixer = - alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer"); + alsa::mixer::Mixer::new(&self.config.device, false).expect("Could not open Alsa mixer"); let simple_element = mixer .find_selem(&SelemId::new(&self.config.control, self.config.index)) .expect("Could not find Alsa mixer control"); @@ -249,7 +250,7 @@ impl AlsaMixer { } let mixer = - alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer"); + alsa::mixer::Mixer::new(&self.config.device, false).expect("Could not open Alsa mixer"); let simple_element = mixer .find_selem(&SelemId::new(&self.config.control, self.config.index)) .expect("Could not find Alsa mixer control"); diff --git a/playback/src/mixer/mod.rs b/playback/src/mixer/mod.rs index ed39582e..a3c7a5a1 100644 --- a/playback/src/mixer/mod.rs +++ b/playback/src/mixer/mod.rs @@ -30,7 +30,7 @@ use self::alsamixer::AlsaMixer; #[derive(Debug, Clone)] pub struct MixerConfig { - pub card: String, + pub device: String, pub control: String, pub index: u32, pub volume_ctrl: VolumeCtrl, @@ -39,7 +39,7 @@ pub struct MixerConfig { impl Default for MixerConfig { fn default() -> MixerConfig { MixerConfig { - card: String::from("default"), + device: String::from("default"), control: String::from("PCM"), index: 0, volume_ctrl: VolumeCtrl::default(), @@ -53,11 +53,19 @@ fn mk_sink(config: MixerConfig) -> Box { Box::new(M::open(config)) } +pub const MIXERS: &[(&str, MixerFn)] = &[ + (SoftMixer::NAME, mk_sink::), // default goes first + #[cfg(feature = "alsa-backend")] + (AlsaMixer::NAME, mk_sink::), +]; + pub fn find(name: Option<&str>) -> Option { - match name { - None | Some(SoftMixer::NAME) => Some(mk_sink::), - #[cfg(feature = "alsa-backend")] - Some(AlsaMixer::NAME) => Some(mk_sink::), - _ => None, + if let Some(name) = name { + MIXERS + .iter() + .find(|mixer| name == mixer.0) + .map(|mixer| mixer.1) + } else { + MIXERS.first().map(|mixer| mixer.1) } } diff --git a/playback/src/mixer/softmixer.rs b/playback/src/mixer/softmixer.rs index 27448237..cefc2de5 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -43,7 +43,7 @@ impl Mixer for SoftMixer { } impl SoftMixer { - pub const NAME: &'static str = "softmixer"; + pub const NAME: &'static str = "softvol"; } struct SoftVolumeApplier { diff --git a/playback/src/player.rs b/playback/src/player.rs index 0249db9c..747c4967 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -1,37 +1,42 @@ -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, + fmt, + future::Future, + io::{self, Read, Seek, SeekFrom}, + mem, + pin::Pin, + process::exit, + 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, stream::futures_unordered::FuturesUnordered, StreamExt, TryFutureExt}; 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, DecoderError, PassthroughDecoder, VorbisDecoder}, + metadata::audio::{AudioFileFormat, AudioItem}, + mixer::AudioFilter, }; -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, AudioError, AudioPacket, PassthroughDecoder, VorbisDecoder}; -use crate::metadata::{AudioItem, FileFormat}; -use crate::mixer::AudioFilter; -use crate::{NUM_CHANNELS, SAMPLES_PER_SECOND}; +use crate::{MS_PER_PAGE, NUM_CHANNELS, PAGES_PER_MS, SAMPLES_PER_SECOND}; const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000; pub const DB_VOLTAGE_RATIO: f64 = 20.0; +pub type PlayerResult = Result<(), Error>; + pub struct Player { commands: Option>, thread_handle: Option>, @@ -67,6 +72,8 @@ struct PlayerInternal { limiter_peak_sample: f64, limiter_factor: f64, limiter_strength: f64, + + auto_normalise_as_album: bool, } enum PlayerCommand { @@ -86,6 +93,7 @@ enum PlayerCommand { AddEventSender(mpsc::UnboundedSender), SetSinkEventCallback(Option), EmitVolumeSetEvent(u16), + SetAutoNormaliseAsAlbum(bool), } #[derive(Debug, Clone)] @@ -213,10 +221,30 @@ pub struct NormalisationData { album_peak: f32, } +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 { 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::()?; let track_peak = file.read_f32::()?; @@ -238,9 +266,10 @@ impl NormalisationData { return 1.0; } - let [gain_db, gain_peak] = match config.normalisation_type { - NormalisationType::Album => [data.album_gain_db, data.album_peak], - NormalisationType::Track => [data.track_gain_db, data.track_peak], + let [gain_db, gain_peak] = if config.normalisation_type == NormalisationType::Album { + [data.album_gain_db, data.album_peak] + } else { + [data.track_gain_db, data.track_peak] }; let normalisation_power = gain_db as f64 + config.normalisation_pregain; @@ -264,7 +293,11 @@ impl NormalisationData { } debug!("Normalisation Data: {:?}", data); - debug!("Normalisation Factor: {:.2}%", normalisation_factor * 100.0); + debug!( + "Calculated Normalisation Factor for {:?}: {:.2}%", + config.normalisation_type, + normalisation_factor * 100.0 + ); normalisation_factor as f64 } @@ -327,11 +360,15 @@ impl Player { limiter_peak_sample: 0.0, limiter_factor: 1.0, limiter_strength: 0.0, + + auto_normalise_as_album: false, }; // 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."); }); @@ -346,7 +383,11 @@ impl Player { } fn command(&self, cmd: PlayerCommand) { - self.commands.as_ref().unwrap().send(cmd).unwrap(); + if let Some(commands) = self.commands.as_ref() { + if let Err(e) = commands.send(cmd) { + error!("Player Commands Error: {}", e); + } + } } pub fn load(&mut self, track_id: SpotifyId, start_playing: bool, position_ms: u32) -> u64 { @@ -406,6 +447,10 @@ impl Player { pub fn emit_volume_set_event(&self, volume: u16) { self.command(PlayerCommand::EmitVolumeSetEvent(volume)); } + + pub fn set_auto_normalise_as_album(&self, setting: bool) { + self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting)); + } } impl Drop for Player { @@ -415,7 +460,7 @@ impl Drop for Player { if let Some(handle) = self.thread_handle.take() { match handle.join() { Ok(_) => (), - Err(_) => error!("Player thread panicked!"), + Err(e) => error!("Player thread Error: {:?}", e), } } } @@ -423,7 +468,7 @@ impl Drop for Player { struct PlayerLoadedTrackData { decoder: Decoder, - normalisation_factor: f64, + normalisation_data: NormalisationData, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, @@ -456,6 +501,7 @@ enum PlayerState { track_id: SpotifyId, play_request_id: u64, decoder: Decoder, + normalisation_data: NormalisationData, normalisation_factor: f64, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, @@ -467,6 +513,7 @@ enum PlayerState { track_id: SpotifyId, play_request_id: u64, decoder: Decoder, + normalisation_data: NormalisationData, normalisation_factor: f64, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, @@ -489,7 +536,10 @@ impl PlayerState { match *self { Stopped | EndOfTrack { .. } | Paused { .. } | Loading { .. } => false, Playing { .. } => true, - Invalid => panic!("invalid state"), + Invalid => { + error!("PlayerState::is_playing in invalid state"); + exit(1); + } } } @@ -514,7 +564,10 @@ impl PlayerState { | Playing { ref mut decoder, .. } => Some(decoder), - Invalid => panic!("invalid state"), + Invalid => { + error!("PlayerState::decoder in invalid state"); + exit(1); + } } } @@ -530,20 +583,24 @@ impl PlayerState { ref mut stream_loader_controller, .. } => Some(stream_loader_controller), - Invalid => panic!("invalid state"), + Invalid => { + error!("PlayerState::stream_loader_controller in invalid state"); + exit(1); + } } } 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, decoder, duration_ms, bytes_per_second, - normalisation_factor, + normalisation_data, stream_loader_controller, stream_position_pcm, .. @@ -553,7 +610,7 @@ impl PlayerState { play_request_id, loaded_track: PlayerLoadedTrackData { decoder, - normalisation_factor, + normalisation_data, stream_loader_controller, bytes_per_second, duration_ms, @@ -561,17 +618,25 @@ impl PlayerState { }, }; } - _ => panic!("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); + } } } 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, decoder, + normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, @@ -583,6 +648,7 @@ impl PlayerState { track_id, play_request_id, decoder, + normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, @@ -592,17 +658,25 @@ impl PlayerState { suggested_to_preload_next_track, }; } - _ => panic!("invalid state"), + _ => { + error!( + "PlayerState::paused_to_playing in invalid state: {:?}", + new_state + ); + exit(1); + } } } 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, decoder, + normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, @@ -615,6 +689,7 @@ impl PlayerState { track_id, play_request_id, decoder, + normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, @@ -623,7 +698,13 @@ impl PlayerState { suggested_to_preload_next_track, }; } - _ => panic!("invalid state"), + _ => { + error!( + "PlayerState::playing_to_paused in invalid state: {:?}", + new_state + ); + exit(1); + } } } } @@ -635,38 +716,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::AAC_24 => 3 * 1024, - FileFormat::AAC_48 => 6 * 1024, - FileFormat::FLAC_FLAC => 112 * 1024, // assume 900 kbps on average - } + 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( @@ -674,43 +760,54 @@ 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) => audio, - Err(_) => { - error!("Unable to load audio item."); + Err(e) => { + error!("Unable to load audio item: {:?}", e); return None; } }; - info!("Loading <{}> with Spotify URI <{}>", audio.name, audio.uri); + info!( + "Loading <{}> with Spotify URI <{}>", + audio.name, audio.spotify_uri + ); let audio = match self.find_available_alternative(audio).await { Some(audio) => audio, None => { - warn!("<{}> is not available", spotify_id.to_uri()); + error!("<{}> is not available", spotify_id.to_uri()); return None; } }; - assert!(audio.duration >= 0); + if audio.duration < 0 { + error!( + "Track duration for <{}> cannot be {}", + spotify_id.to_uri(), + audio.duration + ); + 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 Vorbis, so fall back to it + // TODO: update this logic once we also support MP3 and/or FLAC 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::OGG_VORBIS_160, + AudioFileFormat::OGG_VORBIS_320, ], Bitrate::Bitrate160 => [ - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_96, - FileFormat::OGG_VORBIS_320, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::OGG_VORBIS_320, ], Bitrate::Bitrate320 => [ - FileFormat::OGG_VORBIS_320, - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_96, + AudioFileFormat::OGG_VORBIS_320, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::OGG_VORBIS_96, ], }; @@ -725,7 +822,7 @@ impl PlayerTrackLoader { let (format, file_id) = match entry { Some(t) => t, None => { - warn!("<{}> is not available in any supported format", audio.name); + error!("<{}> is not available in any supported format", audio.name); return None; } }; @@ -745,14 +842,15 @@ impl PlayerTrackLoader { let encrypted_file = match encrypted_file.await { Ok(encrypted_file) => encrypted_file, - Err(_) => { - error!("Unable to load encrypted file."); + Err(e) => { + error!("Unable to load encrypted file: {:?}", e); return None; } }; + let is_cached = encrypted_file.is_cached(); - let stream_loader_controller = encrypted_file.get_stream_loader_controller(); + let stream_loader_controller = encrypted_file.get_stream_loader_controller().ok()?; if play_from_beginning { // No need to seek -> we stream from the beginning @@ -764,22 +862,19 @@ impl PlayerTrackLoader { let key = match self.session.audio_key().request(spotify_id, file_id).await { Ok(key) => key, - Err(_) => { - error!("Unable to load decryption key"); + Err(e) => { + error!("Unable to load decryption key: {:?}", e); return None; } }; let mut decrypted_file = AudioDecrypt::new(key, encrypted_file); - let normalisation_factor = match NormalisationData::parse_from_file(&mut decrypted_file) - { - Ok(normalisation_data) => { - NormalisationData::get_factor(&self.config, normalisation_data) - } + let normalisation_data = match NormalisationData::parse_from_file(&mut decrypted_file) { + Ok(data) => data, Err(_) => { - warn!("Unable to extract normalisation data, using default value."); - 1.0 + warn!("Unable to extract normalisation data, using default values."); + NormalisationData::default() } }; @@ -788,12 +883,12 @@ impl PlayerTrackLoader { let result = if self.config.passthrough { match PassthroughDecoder::new(audio_file) { Ok(result) => Ok(Box::new(result) as Decoder), - Err(e) => Err(AudioError::PassthroughError(e)), + Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())), } } else { match VorbisDecoder::new(audio_file) { Ok(result) => Ok(Box::new(result) as Decoder), - Err(e) => Err(AudioError::VorbisError(e)), + Err(e) => Err(DecoderError::LewtonDecoder(e.to_string())), } }; @@ -805,14 +900,17 @@ impl PlayerTrackLoader { e ); - if self - .session - .cache() - .expect("If the audio file is cached, a cache should exist") - .remove_file(file_id) - .is_err() - { - return None; + match self.session.cache() { + Some(cache) => { + if cache.remove_file(file_id).is_err() { + error!("Error removing file from cache"); + return None; + } + } + None => { + error!("If the audio file is cached, a cache should exist"); + return None; + } } // Just try it again @@ -824,18 +922,23 @@ impl PlayerTrackLoader { } }; - if position_ms != 0 { - if let Err(err) = decoder.seek(position_ms as i64) { - error!("Vorbis error: {}", err); + let mut stream_position_pcm = 0; + let position_pcm = PlayerInternal::position_ms_to_pcm(position_ms); + + if position_pcm > 0 { + stream_loader_controller.set_random_access_mode(); + match decoder.seek(position_pcm) { + Ok(_) => stream_position_pcm = position_pcm, + Err(e) => error!("PlayerTrackLoader::load_track error seeking: {}", e), } stream_loader_controller.set_stream_mode(); - } - let stream_position_pcm = PlayerInternal::position_ms_to_pcm(position_ms); + }; + info!("<{}> ({} ms) loaded", audio.name, audio.duration); return Some(PlayerLoadedTrackData { decoder, - normalisation_factor, + normalisation_data, stream_loader_controller, bytes_per_second, duration_ms, @@ -867,7 +970,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 @@ -887,12 +992,16 @@ impl Future for PlayerInternal { start_playback, ); if let PlayerState::Loading { .. } = self.state { - panic!("The state wasn't changed by start_playback()"); + error!("The state wasn't changed by start_playback()"); + exit(1); } } - Poll::Ready(Err(_)) => { - warn!("Unable to load <{:?}>\nSkipping to next track", track_id); - assert!(self.state.is_loading()); + Poll::Ready(Err(e)) => { + error!( + "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, @@ -951,47 +1060,73 @@ impl Future for PlayerInternal { .. } = self.state { - let packet = decoder.next_packet().expect("Vorbis error"); + 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); - if !passthrough { - if let Some(ref packet) = packet { - *stream_position_pcm += - (packet.samples().len() / NUM_CHANNELS as usize) as u64; - let stream_position_millis = - Self::position_pcm_to_ms(*stream_position_pcm); - - 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 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 + } + }; + if notify_about_position { + *reported_nominal_start_time = Some( + Instant::now() + - Duration::from_millis( + stream_position_millis as u64, + ), + ); + self.send_event(PlayerEvent::Playing { + track_id, + play_request_id, + position_ms: stream_position_millis as u32, + duration_ms, + }); + } + } + Err(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, + }) + } + } } - }; - if notify_about_position { - *reported_nominal_start_time = Some( - Instant::now() - - Duration::from_millis(stream_position_millis as u64), - ); - self.send_event(PlayerEvent::Playing { - track_id, - play_request_id, - position_ms: stream_position_millis as u32, - duration_ms, - }); + } else { + // position, even if irrelevant, must be set so that seek() is called + *stream_position_pcm = duration_ms.into(); } - } - } 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(packet, normalisation_factor); + } + Err(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, + }) + } + } } else { - unreachable!(); + error!("PlayerInternal poll: Invalid PlayerState"); + exit(1); }; } @@ -1040,11 +1175,11 @@ impl Future for PlayerInternal { impl PlayerInternal { fn position_pcm_to_ms(position_pcm: u64) -> u32 { - (position_pcm * 10 / 441) as u32 + (position_pcm as f64 * MS_PER_PAGE) as u32 } fn position_ms_to_pcm(position_ms: u32) -> u64 { - position_ms as u64 * 441 / 10 + (position_ms as f64 * PAGES_PER_MS) as u64 } fn ensure_sink_running(&mut self) { @@ -1055,8 +1190,8 @@ impl PlayerInternal { } match self.sink.start() { Ok(()) => self.sink_status = SinkStatus::Running, - Err(err) => { - error!("Fatal error, could not start audio sink: {}", err); + Err(e) => { + error!("{}", e); exit(1); } } @@ -1078,8 +1213,8 @@ impl PlayerInternal { callback(self.sink_status); } } - Err(err) => { - error!("Fatal error, could not stop audio sink: {}", err); + Err(e) => { + error!("{}", e); exit(1); } } @@ -1126,7 +1261,10 @@ impl PlayerInternal { self.state = PlayerState::Stopped; } PlayerState::Stopped => (), - PlayerState::Invalid => panic!("invalid state"), + PlayerState::Invalid => { + error!("PlayerInternal::handle_player_stop in invalid state"); + exit(1); + } } } @@ -1150,7 +1288,7 @@ impl PlayerInternal { }); self.ensure_sink_running(); } else { - warn!("Player::play called from invalid state"); + error!("Player::play called from invalid state: {:?}", self.state); } } @@ -1174,7 +1312,7 @@ impl PlayerInternal { duration_ms, }); } else { - warn!("Player::pause called from invalid state"); + error!("Player::pause called from invalid state: {:?}", self.state); } } @@ -1183,10 +1321,6 @@ impl PlayerInternal { Some(mut packet) => { if !packet.is_empty() { if let AudioPacket::Samples(ref mut data) = packet { - if let Some(ref editor) = self.audio_filter { - editor.modify_stream(data) - } - if self.config.normalisation && !(f64::abs(normalisation_factor - 1.0) <= f64::EPSILON && self.config.normalisation_method == NormalisationMethod::Basic) @@ -1287,22 +1421,17 @@ impl PlayerInternal { } } } - *sample *= actual_normalisation_factor; - - // Extremely sharp attacks, however unlikely, *may* still clip and provide - // undefined results, so strictly enforce output within [-1.0, 1.0]. - if *sample < -1.0 { - *sample = -1.0; - } else if *sample > 1.0 { - *sample = 1.0; - } } } + + if let Some(ref editor) = self.audio_filter { + editor.modify_stream(data) + } } - if let Err(err) = self.sink.write(&packet, &mut self.converter) { - error!("Fatal error, could not write audio to audio sink: {}", err); + if let Err(e) = self.sink.write(&packet, &mut self.converter) { + error!("{}", e); exit(1); } } @@ -1321,7 +1450,8 @@ impl PlayerInternal { play_request_id, }) } else { - unreachable!(); + error!("PlayerInternal handle_packet: Invalid PlayerState"); + exit(1); } } } @@ -1345,6 +1475,17 @@ impl PlayerInternal { ) { let position_ms = Self::position_pcm_to_ms(loaded_track.stream_position_pcm); + let mut config = self.config.clone(); + if config.normalisation_type == NormalisationType::Auto { + if self.auto_normalise_as_album { + config.normalisation_type = NormalisationType::Album; + } else { + config.normalisation_type = NormalisationType::Track; + } + }; + let normalisation_factor = + NormalisationData::get_factor(&config, loaded_track.normalisation_data); + if start_playback { self.ensure_sink_running(); @@ -1359,7 +1500,8 @@ impl PlayerInternal { track_id, play_request_id, decoder: loaded_track.decoder, - normalisation_factor: loaded_track.normalisation_factor, + normalisation_data: loaded_track.normalisation_data, + normalisation_factor, stream_loader_controller: loaded_track.stream_loader_controller, duration_ms: loaded_track.duration_ms, bytes_per_second: loaded_track.bytes_per_second, @@ -1376,7 +1518,8 @@ impl PlayerInternal { track_id, play_request_id, decoder: loaded_track.decoder, - normalisation_factor: loaded_track.normalisation_factor, + normalisation_data: loaded_track.normalisation_data, + normalisation_factor, stream_loader_controller: loaded_track.stream_loader_controller, duration_ms: loaded_track.duration_ms, bytes_per_second: loaded_track.bytes_per_second, @@ -1429,7 +1572,13 @@ impl PlayerInternal { play_request_id, position_ms, }), - PlayerState::Invalid { .. } => panic!("Player is in an invalid state."), + PlayerState::Invalid { .. } => { + error!( + "Player::handle_command_load called from invalid state: {:?}", + self.state + ); + exit(1); + } } // Now we check at different positions whether we already have a pre-loaded version @@ -1445,24 +1594,30 @@ impl PlayerInternal { if previous_track_id == track_id { let mut loaded_track = match mem::replace(&mut self.state, PlayerState::Invalid) { PlayerState::EndOfTrack { loaded_track, .. } => loaded_track, - _ => unreachable!(), + _ => { + error!("PlayerInternal handle_command_load: Invalid PlayerState"); + exit(1); + } }; - if Self::position_ms_to_pcm(position_ms) != loaded_track.stream_position_pcm { + 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(); - let _ = loaded_track.decoder.seek(position_ms as i64); // This may be blocking. - // But most likely the track is fully - // loaded already because we played - // to the end of it. + // This may be blocking. + match loaded_track.decoder.seek(position_pcm) { + Ok(_) => loaded_track.stream_position_pcm = position_pcm, + Err(e) => error!("PlayerInternal handle_command_load: {}", e), + } loaded_track.stream_loader_controller.set_stream_mode(); - loaded_track.stream_position_pcm = Self::position_ms_to_pcm(position_ms); } self.preload = PlayerPreload::None; self.start_playback(track_id, play_request_id, loaded_track, play); if let PlayerState::Invalid = self.state { - panic!("start_playback() hasn't set a valid player state."); + error!("start_playback() hasn't set a valid player state."); + exit(1); } return; } @@ -1486,11 +1641,18 @@ impl PlayerInternal { { if current_track_id == track_id { // we can use the current decoder. Ensure it's at the correct position. - if Self::position_ms_to_pcm(position_ms) != *stream_position_pcm { + let position_pcm = Self::position_ms_to_pcm(position_ms); + + if position_pcm != *stream_position_pcm { stream_loader_controller.set_random_access_mode(); - let _ = decoder.seek(position_ms as i64); // This may be blocking. + // This may be blocking. + match decoder.seek(position_pcm) { + Ok(_) => *stream_position_pcm = position_pcm, + Err(e) => { + error!("PlayerInternal::handle_command_load error seeking: {}", e) + } + } stream_loader_controller.set_stream_mode(); - *stream_position_pcm = Self::position_ms_to_pcm(position_ms); } // Move the info from the current state into a PlayerLoadedTrackData so we can use @@ -1503,7 +1665,7 @@ impl PlayerInternal { stream_loader_controller, bytes_per_second, duration_ms, - normalisation_factor, + normalisation_data, .. } | PlayerState::Paused { @@ -1512,13 +1674,13 @@ impl PlayerInternal { stream_loader_controller, bytes_per_second, duration_ms, - normalisation_factor, + normalisation_data, .. } = old_state { let loaded_track = PlayerLoadedTrackData { decoder, - normalisation_factor, + normalisation_data, stream_loader_controller, bytes_per_second, duration_ms, @@ -1529,12 +1691,14 @@ impl PlayerInternal { self.start_playback(track_id, play_request_id, loaded_track, play); if let PlayerState::Invalid = self.state { - panic!("start_playback() hasn't set a valid player state."); + error!("start_playback() hasn't set a valid player state."); + exit(1); } return; } else { - unreachable!(); + error!("PlayerInternal handle_command_load: Invalid PlayerState"); + exit(1); } } } @@ -1552,17 +1716,24 @@ impl PlayerInternal { mut loaded_track, } = preload { - if Self::position_ms_to_pcm(position_ms) != loaded_track.stream_position_pcm { + 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(); - let _ = loaded_track.decoder.seek(position_ms as i64); // This may be blocking + // This may be blocking + match loaded_track.decoder.seek(position_pcm) { + Ok(_) => loaded_track.stream_position_pcm = position_pcm, + Err(e) => error!("PlayerInternal handle_command_load: {}", e), + } loaded_track.stream_loader_controller.set_stream_mode(); } self.start_playback(track_id, play_request_id, *loaded_track, play); return; } else { - unreachable!(); + error!("PlayerInternal handle_command_load: Invalid PlayerState"); + exit(1); } } } @@ -1663,12 +1834,14 @@ impl PlayerInternal { } } - fn handle_command_seek(&mut self, position_ms: u32) { + fn handle_command_seek(&mut self, position_ms: u32) -> PlayerResult { if let Some(stream_loader_controller) = self.state.stream_loader_controller() { stream_loader_controller.set_random_access_mode(); } if let Some(decoder) = self.state.decoder() { - match decoder.seek(position_ms as i64) { + let position_pcm = Self::position_ms_to_pcm(position_ms); + + match decoder.seek(position_pcm) { Ok(_) => { if let PlayerState::Playing { ref mut stream_position_pcm, @@ -1679,13 +1852,13 @@ impl PlayerInternal { .. } = self.state { - *stream_position_pcm = Self::position_ms_to_pcm(position_ms); + *stream_position_pcm = position_pcm; } } - Err(err) => error!("Vorbis error: {:?}", err), + Err(e) => error!("PlayerInternal::handle_command_seek error: {}", e), } } else { - warn!("Player::seek called from invalid state"); + error!("Player::seek called from invalid state: {:?}", self.state); } // If we're playing, ensure, that we have enough data leaded to avoid a buffer underrun. @@ -1694,7 +1867,7 @@ impl PlayerInternal { } // 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, @@ -1727,11 +1900,13 @@ 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 { + let result = match cmd { PlayerCommand::Load { track_id, play_request_id, @@ -1741,7 +1916,7 @@ impl PlayerInternal { 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(), @@ -1756,7 +1931,13 @@ impl PlayerInternal { PlayerCommand::EmitVolumeSetEvent(volume) => { self.send_event(PlayerEvent::VolumeSet { volume }) } - } + + PlayerCommand::SetAutoNormaliseAsAlbum(setting) => { + self.auto_normalise_as_album = setting + } + }; + + Ok(result) } fn send_event(&mut self, event: PlayerEvent) { @@ -1789,8 +1970,9 @@ 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 handle = tokio::runtime::Handle::current(); + 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); } @@ -1799,7 +1981,7 @@ impl PlayerInternal { 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, @@ -1822,7 +2004,11 @@ impl PlayerInternal { * 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_blocking(wait_for_data_length) + .map_err(Into::into) + } else { + Ok(()) } } } @@ -1833,8 +2019,8 @@ impl Drop for PlayerInternal { } } -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, @@ -1861,12 +2047,16 @@ impl ::std::fmt::Debug for PlayerCommand { PlayerCommand::EmitVolumeSetEvent(volume) => { f.debug_tuple("VolumeSet").field(&volume).finish() } + PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f + .debug_tuple("SetAutoNormaliseAsAlbum") + .field(&setting) + .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(), @@ -1917,7 +2107,19 @@ struct Subfile { impl Subfile { pub fn new(mut stream: T, offset: u64) -> Subfile { - stream.seek(SeekFrom::Start(offset)).unwrap(); + let target = SeekFrom::Start(offset); + match stream.seek(target) { + Ok(pos) => { + if pos != offset { + error!( + "Subfile::new seeking to {:?} but position is now {:?}", + target, pos + ); + } + } + Err(e) => error!("Subfile new Error: {}", e), + } + Subfile { stream, offset } } } @@ -1936,10 +2138,14 @@ impl Seek for Subfile { }; let newpos = self.stream.seek(pos)?; - if newpos > self.offset { + + if newpos >= self.offset { Ok(newpos - self.offset) } else { - Ok(0) + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "newpos < self.offset", + )) } } } diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index 5c3ae084..5e24f288 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librespot-protocol" -version = "0.2.0" +version = "0.3.1" authors = ["Paul Liétar "] build = "build.rs" description = "The protobuf logic for communicating with Spotify servers" @@ -9,8 +9,8 @@ repository = "https://github.com/librespot-org/librespot" edition = "2018" [dependencies] -protobuf = "2.14.0" +protobuf = "2.25" [build-dependencies] -protobuf-codegen-pure = "2.14.0" +protobuf-codegen-pure = "2.25" glob = "0.3.0" diff --git a/protocol/build.rs b/protocol/build.rs index 53e04bc7..aa107607 100644 --- a/protocol/build.rs +++ b/protocol/build.rs @@ -16,16 +16,25 @@ 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("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("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("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/AdEvent.proto b/protocol/proto/AdEvent.proto index 4b0a3059..69cf82bb 100644 --- a/protocol/proto/AdEvent.proto +++ b/protocol/proto/AdEvent.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -24,4 +24,5 @@ message AdEvent { optional int32 duration = 15; optional bool in_focus = 16; optional float volume = 17; + optional string product_name = 18; } diff --git a/protocol/proto/CacheError.proto b/protocol/proto/CacheError.proto index 8da6196d..ad85c342 100644 --- a/protocol/proto/CacheError.proto +++ b/protocol/proto/CacheError.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -13,4 +13,7 @@ message CacheError { 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/CacheReport.proto b/protocol/proto/CacheReport.proto index c8666ca3..ac034059 100644 --- a/protocol/proto/CacheReport.proto +++ b/protocol/proto/CacheReport.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -8,6 +8,8 @@ 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; 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/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/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/Download.proto b/protocol/proto/Download.proto index 417236bd..0b3faee9 100644 --- a/protocol/proto/Download.proto +++ b/protocol/proto/Download.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -50,4 +50,6 @@ message Download { 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/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/HeadFileDownload.proto b/protocol/proto/HeadFileDownload.proto index acfa87fa..b0d72794 100644 --- a/protocol/proto/HeadFileDownload.proto +++ b/protocol/proto/HeadFileDownload.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -23,4 +23,5 @@ message HeadFileDownload { 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/LocalFilesError.proto b/protocol/proto/LocalFilesError.proto index 49347341..f49d805f 100644 --- a/protocol/proto/LocalFilesError.proto +++ b/protocol/proto/LocalFilesError.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -9,4 +9,5 @@ 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 index 4deff70f..4674e721 100644 --- a/protocol/proto/LocalFilesImport.proto +++ b/protocol/proto/LocalFilesImport.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -12,4 +12,5 @@ message LocalFilesImport { optional int64 failed_tracks = 3; optional int64 matched_tracks = 4; optional string source = 5; + optional int64 invalid_tracks = 6; } diff --git a/protocol/proto/MercuryCacheReport.proto b/protocol/proto/MercuryCacheReport.proto deleted file mode 100644 index 4c9e494f..00000000 --- a/protocol/proto/MercuryCacheReport.proto +++ /dev/null @@ -1,20 +0,0 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) - -syntax = "proto2"; - -package spotify.event_sender.proto; - -option optimize_for = CODE_SIZE; - -message MercuryCacheReport { - optional int64 mercury_cache_version = 1; - optional int64 num_items = 2; - optional int64 num_locked_items = 3; - optional int64 num_expired_items = 4; - optional int64 num_lock_ids = 5; - optional int64 num_expired_lock_ids = 6; - optional int64 max_size = 7; - optional int64 total_size = 8; - optional int64 used_size = 9; - optional int64 free_size = 10; -} diff --git a/protocol/proto/ModuleDebug.proto b/protocol/proto/ModuleDebug.proto deleted file mode 100644 index 87691cd4..00000000 --- a/protocol/proto/ModuleDebug.proto +++ /dev/null @@ -1,11 +0,0 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) - -syntax = "proto2"; - -package spotify.event_sender.proto; - -option optimize_for = CODE_SIZE; - -message ModuleDebug { - optional string blob = 1; -} diff --git a/protocol/proto/OfflineUserPwdLoginNonAuth.proto b/protocol/proto/OfflineUserPwdLoginNonAuth.proto deleted file mode 100644 index 2932bd56..00000000 --- a/protocol/proto/OfflineUserPwdLoginNonAuth.proto +++ /dev/null @@ -1,11 +0,0 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) - -syntax = "proto2"; - -package spotify.event_sender.proto; - -option optimize_for = CODE_SIZE; - -message OfflineUserPwdLoginNonAuth { - optional string connection_type = 1; -} 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/anchor_extended_metadata.proto b/protocol/proto/anchor_extended_metadata.proto deleted file mode 100644 index 24d715a3..00000000 --- a/protocol/proto/anchor_extended_metadata.proto +++ /dev/null @@ -1,14 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto3"; - -package spotify.anchor.extension; - -option objc_class_prefix = "SPT"; -option java_multiple_files = true; -option java_outer_classname = "AnchorExtensionProviderProto"; -option java_package = "com.spotify.anchorextensionprovider.proto"; - -message PodcastCounter { - uint32 counter = 1; -} diff --git a/protocol/proto/apiv1.proto b/protocol/proto/apiv1.proto index deffc3d6..2d8b9c28 100644 --- a/protocol/proto/apiv1.proto +++ b/protocol/proto/apiv1.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// No longer present in Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -39,11 +39,6 @@ message RemoveDeviceRequest { bool is_force_remove = 2; } -message RemoveDeviceResponse { - bool pending = 1; - Device device = 2; -} - message OfflineEnableDeviceResponse { Restrictions restrictions = 1; } 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/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 index a4d7d66f..d0d7f938 100644 --- a/protocol/proto/automix_mode.proto +++ b/protocol/proto/automix_mode.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -6,8 +6,21 @@ 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 { @@ -18,4 +31,11 @@ enum AutomixStyle { RADIO_AIRBAG = 4; SLEEP = 5; MIXED = 6; + CUSTOM = 7; +} + +enum TransitionType { + CUEPOINTS = 0; + CROSSFADE = 1; + GAPLESS = 2; } 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/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/cloud_host_messages.proto b/protocol/proto/cloud_host_messages.proto deleted file mode 100644 index 49949188..00000000 --- a/protocol/proto/cloud_host_messages.proto +++ /dev/null @@ -1,152 +0,0 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) - -syntax = "proto3"; - -package spotify.social_listening.cloud_host; - -option objc_class_prefix = "CloudHost"; -option optimize_for = CODE_SIZE; -option java_package = "com.spotify.social_listening.cloud_host"; - -message LookupSessionRequest { - string token = 1; - JoinType join_type = 2; -} - -message LookupSessionResponse { - oneof response { - Session session = 1; - ErrorCode error = 2; - } -} - -message CreateSessionRequest { - -} - -message CreateSessionResponse { - oneof response { - Session session = 1; - ErrorCode error = 2; - } -} - -message DeleteSessionRequest { - string session_id = 1; -} - -message DeleteSessionResponse { - oneof response { - Session session = 1; - ErrorCode error = 2; - } -} - -message JoinSessionRequest { - string join_token = 1; - Experience experience = 3; -} - -message JoinSessionResponse { - oneof response { - Session session = 1; - ErrorCode error = 2; - } -} - -message LeaveSessionRequest { - string session_id = 1; -} - -message LeaveSessionResponse { - oneof response { - Session session = 1; - ErrorCode error = 2; - } -} - -message GetCurrentSessionRequest { - -} - -message GetCurrentSessionResponse { - oneof response { - Session session = 1; - ErrorCode error = 2; - } -} - -message SessionUpdateRequest { - -} - -message SessionUpdate { - Session session = 1; - SessionUpdateReason reason = 3; - repeated SessionMember updated_session_members = 4; -} - -message SessionUpdateResponse { - oneof response { - SessionUpdate session_update = 1; - ErrorCode error = 2; - } -} - -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 = 8; -} - -message SessionMember { - int64 timestamp = 1; - string member_id = 2; - string username = 3; - string display_name = 4; - string image_url = 5; - string large_image_url = 6; - bool current_user = 7; -} - -enum JoinType { - NotSpecified = 0; - Scanning = 1; - DeepLinking = 2; - DiscoveredDevice = 3; - Frictionless = 4; - NearbyWifi = 5; -} - -enum ErrorCode { - Unknown = 0; - ParseError = 1; - JoinFailed = 1000; - SessionFull = 1001; - FreeUser = 1002; - ScannableError = 1003; - JoinExpiredSession = 1004; - NoExistingSession = 1005; -} - -enum Experience { - UNKNOWN = 0; - BEETHOVEN = 1; - BACH = 2; -} - -enum SessionUpdateReason { - UNKNOWN_UPDATE_REASON = 0; - NEW_SESSION = 1; - USER_JOINED = 2; - USER_LEFT = 3; - SESSION_DELETED = 4; - YOU_LEFT = 5; - YOU_WERE_KICKED = 6; - YOU_JOINED = 7; -} diff --git a/protocol/proto/collection/episode_collection_state.proto b/protocol/proto/collection/episode_collection_state.proto index 403bfbb4..56fcc533 100644 --- a/protocol/proto/collection/episode_collection_state.proto +++ b/protocol/proto/collection/episode_collection_state.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; 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 index 5af95a35..ee6b3efc 100644 --- a/protocol/proto/collection_index.proto +++ b/protocol/proto/collection_index.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -12,7 +12,7 @@ message IndexRepairerState { } message CollectionTrackEntry { - string track_uri = 1; + string uri = 1; string track_name = 2; string album_uri = 3; string album_name = 4; @@ -23,18 +23,16 @@ message CollectionTrackEntry { int64 add_time = 9; } -message CollectionAlbumEntry { - string album_uri = 1; +message CollectionAlbumLikeEntry { + string uri = 1; string album_name = 2; - string album_image_uri = 3; - string artist_uri = 4; - string artist_name = 5; + string creator_uri = 4; + string creator_name = 5; int64 add_time = 6; } -message CollectionMetadataMigratorState { - bytes last_checked_key = 1; - bool migrated_tracks = 2; - bool migrated_albums = 3; - bool migrated_album_tracks = 4; +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 index efe9a847..a855c217 100644 --- a/protocol/proto/collection_platform_requests.proto +++ b/protocol/proto/collection_platform_requests.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -6,10 +6,6 @@ package spotify.collection_platform.proto; option optimize_for = CODE_SIZE; -message CollectionPlatformSimpleRequest { - CollectionSet set = 1; -} - message CollectionPlatformItemsRequest { CollectionSet set = 1; repeated string items = 2; @@ -21,4 +17,5 @@ enum CollectionSet { BAN = 2; LISTENLATER = 3; IGNOREINRECS = 4; + ENHANCED = 5; } diff --git a/protocol/proto/collection_platform_responses.proto b/protocol/proto/collection_platform_responses.proto index fd236c12..6b7716d8 100644 --- a/protocol/proto/collection_platform_responses.proto +++ b/protocol/proto/collection_platform_responses.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -10,8 +10,13 @@ message CollectionPlatformSimpleResponse { string error_msg = 1; } +message CollectionPlatformItem { + string uri = 1; + int64 add_time = 2; +} + message CollectionPlatformItemsResponse { - repeated string items = 1; + repeated CollectionPlatformItem items = 1; } message CollectionPlatformContainsResponse { diff --git a/protocol/proto/collection_storage.proto b/protocol/proto/collection_storage.proto deleted file mode 100644 index 1dd4f034..00000000 --- a/protocol/proto/collection_storage.proto +++ /dev/null @@ -1,20 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto2"; - -package spotify.collection.proto.storage; - -import "collection2.proto"; - -option optimize_for = CODE_SIZE; - -message CollectionHeader { - optional bytes etag = 1; -} - -message CollectionCache { - optional CollectionHeader header = 1; - optional CollectionItems collection = 2; - optional CollectionItems pending = 3; - optional uint32 collection_item_limit = 4; -} diff --git a/protocol/proto/composite_formats_node.proto b/protocol/proto/composite_formats_node.proto deleted file mode 100644 index 75717c98..00000000 --- a/protocol/proto/composite_formats_node.proto +++ /dev/null @@ -1,31 +0,0 @@ -// 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 InjectionSegment { - required string track_uri = 1; - optional int64 start = 2; - optional int64 stop = 3; - required int64 duration = 4; -} - -message InjectionModel { - required string episode_uri = 1; - required int64 total_duration = 2; - repeated InjectionSegment segments = 3; -} - -message CompositeFormatsPrototypeNode { - required string mode = 1; - optional InjectionModel injection_model = 2; - required uint32 index = 3; - required TrackInstantiator instantiator = 4; - optional TrackInstance track = 5; -} diff --git a/protocol/proto/connect.proto b/protocol/proto/connect.proto index 310a5b55..d6485252 100644 --- a/protocol/proto/connect.proto +++ b/protocol/proto/connect.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -70,7 +70,7 @@ message DeviceInfo { Capabilities capabilities = 4; repeated DeviceMetadata metadata = 5; string device_software_version = 6; - devices.DeviceType device_type = 7; + spotify.connectstate.devices.DeviceType device_type = 7; string spirc_version = 9; string device_id = 10; bool is_private_session = 11; @@ -82,11 +82,14 @@ message DeviceInfo { string product_id = 17; string deduplication_id = 18; uint32 selected_alias_id = 19; - map device_aliases = 20; + 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; @@ -133,8 +136,9 @@ message Capabilities { 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"; + //reserved 1, 4, 24, "supported_contexts", "supports_lossless_audio"; } message CapabilitySupportDetails { 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_core.proto b/protocol/proto/context_core.proto deleted file mode 100644 index 1e49afaf..00000000 --- a/protocol/proto/context_core.proto +++ /dev/null @@ -1,14 +0,0 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) - -syntax = "proto3"; - -package spotify.event_sender.proto; - -option optimize_for = CODE_SIZE; - -message Core { - string os_name = 1; - string os_version = 2; - string device_id = 3; - string client_version = 4; -} 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_node.proto b/protocol/proto/context_node.proto index 8ff3cb28..82dd9d62 100644 --- a/protocol/proto/context_node.proto +++ b/protocol/proto/context_node.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -20,4 +20,5 @@ message ContextNode { 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_player_ng.proto b/protocol/proto/context_player_ng.proto deleted file mode 100644 index e61f011e..00000000 --- a/protocol/proto/context_player_ng.proto +++ /dev/null @@ -1,12 +0,0 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) - -syntax = "proto2"; - -package spotify.player.proto; - -option optimize_for = CODE_SIZE; - -message ContextPlayerNg { - map player_model = 1; - optional uint64 playback_position = 2; -} diff --git a/protocol/proto/context_sdk.proto b/protocol/proto/context_sdk.proto index dc5d3236..419f7aa5 100644 --- a/protocol/proto/context_sdk.proto +++ b/protocol/proto/context_sdk.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -8,4 +8,5 @@ option optimize_for = CODE_SIZE; message Sdk { string version_name = 1; + string type = 2; } diff --git a/protocol/proto/core_configuration_applied_non_auth.proto b/protocol/proto/core_configuration_applied_non_auth.proto deleted file mode 100644 index d7c132dc..00000000 --- a/protocol/proto/core_configuration_applied_non_auth.proto +++ /dev/null @@ -1,11 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto3"; - -package spotify.remote_config.proto; - -option optimize_for = CODE_SIZE; - -message CoreConfigurationAppliedNonAuth { - string configuration_assignment_id = 1; -} diff --git a/protocol/proto/cosmos_changes_request.proto b/protocol/proto/cosmos_changes_request.proto index 47cd584f..2e4b7040 100644 --- a/protocol/proto/cosmos_changes_request.proto +++ b/protocol/proto/cosmos_changes_request.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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 index 2709b30a..9e586021 100644 --- a/protocol/proto/cosmos_decorate_request.proto +++ b/protocol/proto/cosmos_decorate_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -22,6 +22,7 @@ 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 { diff --git a/protocol/proto/cosmos_get_album_list_request.proto b/protocol/proto/cosmos_get_album_list_request.proto index 741e9f49..448dcd46 100644 --- a/protocol/proto/cosmos_get_album_list_request.proto +++ b/protocol/proto/cosmos_get_album_list_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -8,6 +8,7 @@ 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 { diff --git a/protocol/proto/cosmos_get_artist_list_request.proto b/protocol/proto/cosmos_get_artist_list_request.proto index b8ccb662..1dfeedba 100644 --- a/protocol/proto/cosmos_get_artist_list_request.proto +++ b/protocol/proto/cosmos_get_artist_list_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -8,6 +8,7 @@ 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 { diff --git a/protocol/proto/cosmos_get_episode_list_request.proto b/protocol/proto/cosmos_get_episode_list_request.proto index 8168fbfe..437a621f 100644 --- a/protocol/proto/cosmos_get_episode_list_request.proto +++ b/protocol/proto/cosmos_get_episode_list_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -9,6 +9,7 @@ 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 { diff --git a/protocol/proto/cosmos_get_show_list_request.proto b/protocol/proto/cosmos_get_show_list_request.proto index 880f7cea..e2b8a578 100644 --- a/protocol/proto/cosmos_get_show_list_request.proto +++ b/protocol/proto/cosmos_get_show_list_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -8,6 +8,7 @@ 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 { diff --git a/protocol/proto/cosmos_get_tags_info_request.proto b/protocol/proto/cosmos_get_tags_info_request.proto index fe666025..5480c7bc 100644 --- a/protocol/proto/cosmos_get_tags_info_request.proto +++ b/protocol/proto/cosmos_get_tags_info_request.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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 { diff --git a/protocol/proto/cosmos_get_track_list_metadata_request.proto b/protocol/proto/cosmos_get_track_list_metadata_request.proto index 8a02c962..a4586249 100644 --- a/protocol/proto/cosmos_get_track_list_metadata_request.proto +++ b/protocol/proto/cosmos_get_track_list_metadata_request.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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 { diff --git a/protocol/proto/cosmos_get_track_list_request.proto b/protocol/proto/cosmos_get_track_list_request.proto index c92320f7..95c83410 100644 --- a/protocol/proto/cosmos_get_track_list_request.proto +++ b/protocol/proto/cosmos_get_track_list_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -9,6 +9,7 @@ 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 { diff --git a/protocol/proto/cosmos_get_unplayed_episodes_request.proto b/protocol/proto/cosmos_get_unplayed_episodes_request.proto index 8957ae56..09339c78 100644 --- a/protocol/proto/cosmos_get_unplayed_episodes_request.proto +++ b/protocol/proto/cosmos_get_unplayed_episodes_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -9,6 +9,7 @@ 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 { diff --git a/protocol/proto/decorate_request.proto b/protocol/proto/decorate_request.proto index cad3f526..ff1fa0ed 100644 --- a/protocol/proto/decorate_request.proto +++ b/protocol/proto/decorate_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -6,6 +6,7 @@ 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"; @@ -14,8 +15,11 @@ 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 { @@ -24,13 +28,14 @@ message Show { 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 show_cosmos.proto.EpisodePlayState episode_play_state = 4; + 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; @@ -38,6 +43,7 @@ message Episode { 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; } diff --git a/protocol/proto/dependencies/session_control.proto b/protocol/proto/dependencies/session_control.proto deleted file mode 100644 index f4e6d744..00000000 --- a/protocol/proto/dependencies/session_control.proto +++ /dev/null @@ -1,121 +0,0 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) - -syntax = "proto3"; - -package com.spotify.sessioncontrol.api.v1; - -option java_multiple_files = true; -option optimize_for = CODE_SIZE; -option java_package = "com.spotify.sessioncontrol.api.v1.proto"; - -service SessionControlService { - rpc GetCurrentSession(GetCurrentSessionRequest) returns (GetCurrentSessionResponse); - rpc GetCurrentSessionOrNew(GetCurrentSessionOrNewRequest) returns (GetCurrentSessionOrNewResponse); - rpc JoinSession(JoinSessionRequest) returns (JoinSessionResponse); - rpc GetSessionInfo(GetSessionInfoRequest) returns (GetSessionInfoResponse); - rpc LeaveSession(LeaveSessionRequest) returns (LeaveSessionResponse); - rpc EndSession(EndSessionRequest) returns (EndSessionResponse); - rpc VerifyCommand(VerifyCommandRequest) returns (VerifyCommandResponse); -} - -message SessionUpdate { - Session session = 1; - SessionUpdateReason reason = 2; - repeated SessionMember updated_session_members = 3; -} - -message GetCurrentSessionRequest { - -} - -message GetCurrentSessionResponse { - Session session = 1; -} - -message GetCurrentSessionOrNewRequest { - string fallback_device_id = 1; -} - -message GetCurrentSessionOrNewResponse { - Session session = 1; -} - -message JoinSessionRequest { - string join_token = 1; - string device_id = 2; - Experience experience = 3; -} - -message JoinSessionResponse { - Session session = 1; -} - -message GetSessionInfoRequest { - string join_token = 1; -} - -message GetSessionInfoResponse { - Session session = 1; -} - -message LeaveSessionRequest { - -} - -message LeaveSessionResponse { - -} - -message EndSessionRequest { - string session_id = 1; -} - -message EndSessionResponse { - -} - -message VerifyCommandRequest { - string session_id = 1; - string command = 2; -} - -message VerifyCommandResponse { - bool allowed = 1; -} - -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 = 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; -} - -enum SessionUpdateReason { - UNKNOWN_UPDATE_REASON = 0; - NEW_SESSION = 1; - USER_JOINED = 2; - USER_LEFT = 3; - SESSION_DELETED = 4; - YOU_LEFT = 5; - YOU_WERE_KICKED = 6; - YOU_JOINED = 7; -} - -enum Experience { - UNKNOWN = 0; - BEETHOVEN = 1; - BACH = 2; -} 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/es_command_options.proto b/protocol/proto/es_command_options.proto index c261ca27..0a37e801 100644 --- a/protocol/proto/es_command_options.proto +++ b/protocol/proto/es_command_options.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -12,4 +12,5 @@ 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_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_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_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_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_seek_to.proto b/protocol/proto/es_seek_to.proto index 0ef8aa4b..59073cf9 100644 --- a/protocol/proto/es_seek_to.proto +++ b/protocol/proto/es_seek_to.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -15,4 +15,11 @@ 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_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/event_entity.proto b/protocol/proto/event_entity.proto index 28ec0b5a..06239d59 100644 --- a/protocol/proto/event_entity.proto +++ b/protocol/proto/event_entity.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -7,12 +7,12 @@ package spotify.event_sender.proto; option optimize_for = CODE_SIZE; message EventEntity { - int32 file_format_version = 1; + uint32 file_format_version = 1; string event_name = 2; bytes sequence_id = 3; - int64 sequence_number = 4; + uint64 sequence_number = 4; bytes payload = 5; string owner = 6; bool authenticated = 7; - int64 record_id = 8; + uint64 record_id = 8; } diff --git a/protocol/proto/extension_descriptor_type.proto b/protocol/proto/extension_descriptor_type.proto index a2009d68..2ca05713 100644 --- a/protocol/proto/extension_descriptor_type.proto +++ b/protocol/proto/extension_descriptor_type.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -26,4 +26,5 @@ enum ExtensionDescriptorType { INSTRUMENT = 4; TIME = 5; ERA = 6; + AESTHETIC = 7; } diff --git a/protocol/proto/extension_kind.proto b/protocol/proto/extension_kind.proto index 97768b67..02444dea 100644 --- a/protocol/proto/extension_kind.proto +++ b/protocol/proto/extension_kind.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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; @@ -43,4 +44,11 @@ enum ExtensionKind { 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/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/google/protobuf/descriptor.proto b/protocol/proto/google/protobuf/descriptor.proto index 7f91c408..884a5151 100644 --- a/protocol/proto/google/protobuf/descriptor.proto +++ b/protocol/proto/google/protobuf/descriptor.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -189,7 +189,7 @@ message MessageOptions { extensions 1000 to max; - reserved 8, 9; + reserved 4, 5, 6, 8, 9; } message FieldOptions { 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/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/format.proto b/protocol/proto/media_format.proto similarity index 84% rename from protocol/proto/format.proto rename to protocol/proto/media_format.proto index 3a75b4df..c54f6323 100644 --- a/protocol/proto/format.proto +++ b/protocol/proto/media_format.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -7,7 +7,7 @@ package spotify.stream_reporting_esperanto.proto; option objc_class_prefix = "ESP"; option java_package = "com.spotify.stream_reporting_esperanto.proto"; -enum Format { +enum MediaFormat { FORMAT_UNKNOWN = 0; FORMAT_OGG_VORBIS_96 = 1; FORMAT_OGG_VORBIS_160 = 2; @@ -27,4 +27,6 @@ enum Format { 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 index a6a97681..6e280259 100644 --- a/protocol/proto/media_manifest.proto +++ b/protocol/proto/media_manifest.proto @@ -1,8 +1,8 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; -package spotify.media_manifest; +package spotify.media_manifest.proto; option optimize_for = CODE_SIZE; @@ -33,9 +33,12 @@ message File { message ExternalFile { string method = 1; - string url = 2; - bytes body = 3; - bool is_webgate_endpoint = 4; + bytes body = 4; + + oneof endpoint { + string url = 2; + string service = 3; + } } message FileIdFile { diff --git a/protocol/proto/media_type.proto b/protocol/proto/media_type.proto index 5527922f..2d8def46 100644 --- a/protocol/proto/media_type.proto +++ b/protocol/proto/media_type.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -8,7 +8,6 @@ option objc_class_prefix = "ESP"; option java_package = "com.spotify.stream_reporting_esperanto.proto"; enum MediaType { - MEDIA_TYPE_UNSET = 0; - AUDIO = 1; - VIDEO = 2; + AUDIO = 0; + VIDEO = 1; } 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/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 a6d3aded..056dbcfa 100644 --- a/protocol/proto/metadata.proto +++ b/protocol/proto/metadata.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -140,6 +140,7 @@ message Show { repeated Availability availability = 78; optional string trailer_uri = 83; optional bool music_and_talk = 85; + optional bool is_audiobook = 89; } message Episode { @@ -173,6 +174,8 @@ message Episode { } optional bool music_and_talk = 91; + repeated ContentRating content_rating = 95; + optional bool is_audiobook_chapter = 96; } message Licensor { diff --git a/protocol/proto/metadata/episode_metadata.proto b/protocol/proto/metadata/episode_metadata.proto index 9f47deee..5d4a0b25 100644 --- a/protocol/proto/metadata/episode_metadata.proto +++ b/protocol/proto/metadata/episode_metadata.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; @@ -56,4 +57,7 @@ message EpisodeMetadata { 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/show_metadata.proto b/protocol/proto/metadata/show_metadata.proto index 8beaf97b..9b9891d3 100644 --- a/protocol/proto/metadata/show_metadata.proto +++ b/protocol/proto/metadata/show_metadata.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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; @@ -25,4 +26,6 @@ message ShowMetadata { 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_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/offline_playlists_containing.proto b/protocol/proto/offline_playlists_containing.proto index 19106b0c..3d75865f 100644 --- a/protocol/proto/offline_playlists_containing.proto +++ b/protocol/proto/offline_playlists_containing.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; diff --git a/protocol/proto/on_demand_set_cosmos_request.proto b/protocol/proto/on_demand_set_cosmos_request.proto index 28b70c16..72d4d3d9 100644 --- a/protocol/proto/on_demand_set_cosmos_request.proto +++ b/protocol/proto/on_demand_set_cosmos_request.proto @@ -1,10 +1,13 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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; diff --git a/protocol/proto/on_demand_set_cosmos_response.proto b/protocol/proto/on_demand_set_cosmos_response.proto index 3e5d708f..8ca68cbe 100644 --- a/protocol/proto/on_demand_set_cosmos_response.proto +++ b/protocol/proto/on_demand_set_cosmos_response.proto @@ -1,10 +1,13 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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 index 23e064ad..a5337320 100644 --- a/protocol/proto/pin_request.proto +++ b/protocol/proto/pin_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -19,6 +19,7 @@ message PinResponse { } bool has_maximum_pinned_items = 2; + int32 maximum_pinned_items = 3; string error = 99; } diff --git a/protocol/proto/play_reason.proto b/protocol/proto/play_reason.proto index 6ebfc914..04bba83f 100644 --- a/protocol/proto/play_reason.proto +++ b/protocol/proto/play_reason.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -8,26 +8,25 @@ option objc_class_prefix = "ESP"; option java_package = "com.spotify.stream_reporting_esperanto.proto"; enum PlayReason { - REASON_UNSET = 0; - REASON_APP_LOAD = 1; - REASON_BACK_BTN = 2; - REASON_CLICK_ROW = 3; - REASON_CLICK_SIDE = 4; - REASON_END_PLAY = 5; - REASON_FWD_BTN = 6; - REASON_INTERRUPTED = 7; - REASON_LOGOUT = 8; - REASON_PLAY_BTN = 9; - REASON_POPUP = 10; - REASON_REMOTE = 11; - REASON_SONG_DONE = 12; - REASON_TRACK_DONE = 13; - REASON_TRACK_ERROR = 14; - REASON_PREVIEW = 15; - REASON_PLAY_REASON_UNKNOWN = 16; - REASON_URI_OPEN = 17; - REASON_BACKGROUNDED = 18; - REASON_OFFLINE = 19; - REASON_UNEXPECTED_EXIT = 20; - REASON_UNEXPECTED_EXIT_WHILE_PAUSED = 21; + 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/play_source.proto b/protocol/proto/play_source.proto deleted file mode 100644 index e4db2b9a..00000000 --- a/protocol/proto/play_source.proto +++ /dev/null @@ -1,47 +0,0 @@ -// 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"; - -enum PlaySource { - SOURCE_UNSET = 0; - SOURCE_ALBUM = 1; - SOURCE_ARTIST = 2; - SOURCE_ARTIST_RADIO = 3; - SOURCE_COLLECTION = 4; - SOURCE_DEVICE_SECTION = 5; - SOURCE_EXTERNAL_DEVICE = 6; - SOURCE_EXT_LINK = 7; - SOURCE_INBOX = 8; - SOURCE_LIBRARY = 9; - SOURCE_LIBRARY_COLLECTION = 10; - SOURCE_LIBRARY_COLLECTION_ALBUM = 11; - SOURCE_LIBRARY_COLLECTION_ARTIST = 12; - SOURCE_LIBRARY_COLLECTION_MISSING_ALBUM = 13; - SOURCE_LOCAL_FILES = 14; - SOURCE_PENDAD = 15; - SOURCE_PLAYLIST = 16; - SOURCE_PLAYLIST_OWNED_BY_OTHER_COLLABORATIVE = 17; - SOURCE_PLAYLIST_OWNED_BY_OTHER_NON_COLLABORATIVE = 18; - SOURCE_PLAYLIST_OWNED_BY_SELF_COLLABORATIVE = 19; - SOURCE_PLAYLIST_OWNED_BY_SELF_NON_COLLABORATIVE = 20; - SOURCE_PLAYLIST_FOLDER = 21; - SOURCE_PLAYLISTS = 22; - SOURCE_PLAY_QUEUE = 23; - SOURCE_PLUGIN_API = 24; - SOURCE_PROFILE = 25; - SOURCE_PURCHASES = 26; - SOURCE_RADIO = 27; - SOURCE_RTMP = 28; - SOURCE_SEARCH = 29; - SOURCE_SHOW = 30; - SOURCE_TEMP_PLAYLIST = 31; - SOURCE_TOPLIST = 32; - SOURCE_TRACK_SET = 33; - SOURCE_PLAY_SOURCE_UNKNOWN = 34; - SOURCE_QUICK_MENU = 35; -} diff --git a/protocol/proto/playback_cosmos.proto b/protocol/proto/playback_cosmos.proto index 83a905fd..b2ae4f96 100644 --- a/protocol/proto/playback_cosmos.proto +++ b/protocol/proto/playback_cosmos.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -60,11 +60,12 @@ message InfoResponse { float gain_adjustment = 13; bool has_loudness = 14; float loudness = 15; - string file_origin = 16; string strategy = 17; int32 target_bitrate = 18; int32 advised_bitrate = 19; bool target_file_available = 20; + + reserved 16; } message FormatsResponse { 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/played_state/show_played_state.proto b/protocol/proto/played_state/show_played_state.proto index 08910f93..47f13ec7 100644 --- a/protocol/proto/played_state/show_played_state.proto +++ b/protocol/proto/played_state/show_played_state.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; diff --git a/protocol/proto/playlist4_external.proto b/protocol/proto/playlist4_external.proto index 0a5d7084..2a7b44b9 100644 --- a/protocol/proto/playlist4_external.proto +++ b/protocol/proto/playlist4_external.proto @@ -1,9 +1,11 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; @@ -19,6 +21,8 @@ message MetaItem { 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 { @@ -187,16 +191,45 @@ message SelectedListContent { 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 bytes uri = 1; + required string uri = 1; optional bytes revision = 2; } -message ModifyReply { - required bytes 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 { @@ -214,6 +247,19 @@ message PlaylistModificationInfo { 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; @@ -237,3 +283,10 @@ enum ItemAttributeKind { 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_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_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_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 index babab040..96e9c06d 100644 --- a/protocol/proto/playlist_permission.proto +++ b/protocol/proto/playlist_permission.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -19,6 +19,7 @@ message Capabilities { 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 { @@ -52,6 +53,10 @@ message SetPermissionResponse { optional Permission resulting_permission = 1; } +message GetMemberPermissionsResponse { + map member_permissions = 1; +} + message Permissions { optional Permission base_permission = 1; } @@ -67,6 +72,21 @@ 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; diff --git a/protocol/proto/playlist_playlist_state.proto b/protocol/proto/playlist_playlist_state.proto index 4356fe65..5663252c 100644 --- a/protocol/proto/playlist_playlist_state.proto +++ b/protocol/proto/playlist_playlist_state.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; @@ -42,6 +43,7 @@ message PlaylistMetadata { optional Allows allows = 18; optional string load_state = 19; optional User made_for = 20; + repeated cosmos_util.proto.Extension extension = 21; } message PlaylistOfflineState { diff --git a/protocol/proto/playlist_request.proto b/protocol/proto/playlist_request.proto index cb452f63..52befb1f 100644 --- a/protocol/proto/playlist_request.proto +++ b/protocol/proto/playlist_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -17,6 +17,7 @@ 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"; @@ -86,4 +87,5 @@ message Response { 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_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_track_state.proto b/protocol/proto/playlist_track_state.proto index 5bd64ae2..cd55947f 100644 --- a/protocol/proto/playlist_track_state.proto +++ b/protocol/proto/playlist_track_state.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; diff --git a/protocol/proto/playlist_user_state.proto b/protocol/proto/playlist_user_state.proto index 510630ca..86c07dee 100644 --- a/protocol/proto/playlist_user_state.proto +++ b/protocol/proto/playlist_user_state.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -14,4 +14,5 @@ message User { optional string display_name = 3; optional string image_uri = 4; optional string thumbnail_uri = 5; + optional int32 color = 6; } diff --git a/protocol/proto/playlist_v1_uri.proto b/protocol/proto/playlist_v1_uri.proto deleted file mode 100644 index 76c9d797..00000000 --- a/protocol/proto/playlist_v1_uri.proto +++ /dev/null @@ -1,15 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto2"; - -package spotify.player.proto; - -option optimize_for = CODE_SIZE; - -message PlaylistV1UriRequest { - repeated string v2_uris = 1; -} - -message PlaylistV1UriReply { - map v2_uri_to_v1_uri = 1; -} 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_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/policy/album_decoration_policy.proto b/protocol/proto/policy/album_decoration_policy.proto index a20cf324..359347d4 100644 --- a/protocol/proto/policy/album_decoration_policy.proto +++ b/protocol/proto/policy/album_decoration_policy.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -19,3 +19,15 @@ message AlbumDecorationPolicy { 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 index f8d8b2cb..0419dc31 100644 --- a/protocol/proto/policy/artist_decoration_policy.proto +++ b/protocol/proto/policy/artist_decoration_policy.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -14,3 +14,18 @@ message ArtistDecorationPolicy { 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 index 77489834..467426bd 100644 --- a/protocol/proto/policy/episode_decoration_policy.proto +++ b/protocol/proto/policy/episode_decoration_policy.proto @@ -1,9 +1,11 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; @@ -29,6 +31,9 @@ message EpisodeDecorationPolicy { 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 { @@ -47,6 +52,7 @@ message EpisodePlayedStateDecorationPolicy { bool is_played = 2; bool playable = 3; bool playability_restriction = 4; + bool last_played_at = 5; } message PodcastSegmentsPolicy { diff --git a/protocol/proto/policy/playlist_decoration_policy.proto b/protocol/proto/policy/playlist_decoration_policy.proto index 9975279c..a6aef1b7 100644 --- a/protocol/proto/policy/playlist_decoration_policy.proto +++ b/protocol/proto/policy/playlist_decoration_policy.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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; @@ -57,4 +58,6 @@ message PlaylistDecorationPolicy { 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/show_decoration_policy.proto b/protocol/proto/policy/show_decoration_policy.proto index 02ae2f3e..2e5e2020 100644 --- a/protocol/proto/policy/show_decoration_policy.proto +++ b/protocol/proto/policy/show_decoration_policy.proto @@ -1,9 +1,11 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; @@ -24,6 +26,8 @@ message ShowDecorationPolicy { 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 { diff --git a/protocol/proto/policy/track_decoration_policy.proto b/protocol/proto/policy/track_decoration_policy.proto index 45162008..aa71f497 100644 --- a/protocol/proto/policy/track_decoration_policy.proto +++ b/protocol/proto/policy/track_decoration_policy.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -34,3 +34,15 @@ message TrackPlayedStateDecorationPolicy { 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 index 4f72e974..f2c342eb 100644 --- a/protocol/proto/policy/user_decoration_policy.proto +++ b/protocol/proto/policy/user_decoration_policy.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -14,6 +14,7 @@ message UserDecorationPolicy { bool name = 3; bool image = 4; bool thumbnail = 5; + bool color = 6; } message CollaboratorPolicy { diff --git a/protocol/proto/prepare_play_options.proto b/protocol/proto/prepare_play_options.proto index cfaeab14..cb27650d 100644 --- a/protocol/proto/prepare_play_options.proto +++ b/protocol/proto/prepare_play_options.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -6,6 +6,7 @@ package spotify.player.proto; import "context_player_options.proto"; import "player_license.proto"; +import "skip_to_track.proto"; option optimize_for = CODE_SIZE; @@ -13,4 +14,24 @@ 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/profile_cache.proto b/protocol/proto/profile_cache.proto deleted file mode 100644 index 8162612f..00000000 --- a/protocol/proto/profile_cache.proto +++ /dev/null @@ -1,19 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto3"; - -package spotify.profile.proto; - -import "identity.proto"; - -option optimize_for = CODE_SIZE; - -message CachedProfile { - identity.proto.DecorationData profile = 1; - int64 expires_at = 2; - bool pinned = 3; -} - -message ProfileCacheFile { - repeated CachedProfile cached_profiles = 1; -} 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 index 4552c1b2..9df7caa7 100644 --- a/protocol/proto/property_definition.proto +++ b/protocol/proto/property_definition.proto @@ -25,7 +25,7 @@ message PropertyDefinition { EnumSpec enum_spec = 7; } - reserved 2, "hash"; + //reserved 2, "hash"; message BoolSpec { bool default = 1; 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/rc_dummy_property_resolved.proto b/protocol/proto/rc_dummy_property_resolved.proto deleted file mode 100644 index 9c5e2aaf..00000000 --- a/protocol/proto/rc_dummy_property_resolved.proto +++ /dev/null @@ -1,12 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto3"; - -package spotify.remote_config.proto; - -option optimize_for = CODE_SIZE; - -message RcDummyPropertyResolved { - string resolved_value = 1; - string configuration_assignment_id = 2; -} diff --git a/protocol/proto/rcs.proto b/protocol/proto/rcs.proto index ed8405c2..00e86103 100644 --- a/protocol/proto/rcs.proto +++ b/protocol/proto/rcs.proto @@ -52,7 +52,7 @@ message ClientPropertySet { message ComponentInfo { string name = 3; - reserved 1, 2, "owner", "tags"; + //reserved 1, 2, "owner", "tags"; } string property_set_key = 7; diff --git a/protocol/proto/record_id.proto b/protocol/proto/record_id.proto index 54fa24a3..167c0ecd 100644 --- a/protocol/proto/record_id.proto +++ b/protocol/proto/record_id.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -7,5 +7,5 @@ package spotify.event_sender.proto; option optimize_for = CODE_SIZE; message RecordId { - int64 value = 1; + uint64 value = 1; } diff --git a/protocol/proto/resolve.proto b/protocol/proto/resolve.proto index 5f2cd9b8..793b8c5a 100644 --- a/protocol/proto/resolve.proto +++ b/protocol/proto/resolve.proto @@ -17,7 +17,7 @@ message ResolveRequest { BackendContext backend_context = 12 [deprecated = true]; } - reserved 4, 5, "custom_context", "projection"; + //reserved 4, 5, "custom_context", "projection"; } message ResolveResponse { diff --git a/protocol/proto/resolve_configuration_error.proto b/protocol/proto/resolve_configuration_error.proto deleted file mode 100644 index 22f2e1fb..00000000 --- a/protocol/proto/resolve_configuration_error.proto +++ /dev/null @@ -1,14 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto3"; - -package spotify.remote_config.proto; - -option optimize_for = CODE_SIZE; - -message ResolveConfigurationError { - string error_message = 1; - int64 status_code = 2; - string client_id = 3; - string client_version = 4; -} diff --git a/protocol/proto/response_status.proto b/protocol/proto/response_status.proto index a9ecadd7..5709571f 100644 --- a/protocol/proto/response_status.proto +++ b/protocol/proto/response_status.proto @@ -1,10 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; package spotify.playlist_esperanto.proto; -option objc_class_prefix = "ESP"; +option objc_class_prefix = "SPTPlaylistEsperanto"; option java_multiple_files = true; option optimize_for = CODE_SIZE; option java_package = "spotify.playlist.esperanto.proto"; diff --git a/protocol/proto/rootlist_request.proto b/protocol/proto/rootlist_request.proto index 80af73f0..ae055475 100644 --- a/protocol/proto/rootlist_request.proto +++ b/protocol/proto/rootlist_request.proto @@ -1,13 +1,15 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; @@ -18,6 +20,7 @@ message Playlist { 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 { diff --git a/protocol/proto/sequence_number_entity.proto b/protocol/proto/sequence_number_entity.proto index cd97392c..a3b88c81 100644 --- a/protocol/proto/sequence_number_entity.proto +++ b/protocol/proto/sequence_number_entity.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -7,8 +7,8 @@ package spotify.event_sender.proto; option optimize_for = CODE_SIZE; message SequenceNumberEntity { - int32 file_format_version = 1; + uint32 file_format_version = 1; string event_name = 2; bytes sequence_id = 3; - int64 sequence_number_next = 4; + uint64 sequence_number_next = 4; } 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 index 3516cdfd..eddc0342 100644 --- a/protocol/proto/show_access.proto +++ b/protocol/proto/show_access.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -11,10 +11,13 @@ 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; } } @@ -31,3 +34,17 @@ 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 index 001fafee..b780dbb6 100644 --- a/protocol/proto/show_episode_state.proto +++ b/protocol/proto/show_episode_state.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -16,10 +16,3 @@ message EpisodeOfflineState { optional string offline_state = 1; optional uint32 sync_progress = 2; } - -message EpisodePlayState { - optional uint32 time_left = 1; - optional bool is_playable = 2; - optional bool is_played = 3; - optional uint64 last_played_at = 4; -} diff --git a/protocol/proto/show_request.proto b/protocol/proto/show_request.proto index 0f40a1bd..3624fa04 100644 --- a/protocol/proto/show_request.proto +++ b/protocol/proto/show_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto2"; @@ -6,6 +6,7 @@ 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"; @@ -13,7 +14,10 @@ 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 { @@ -21,9 +25,10 @@ message Item { optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; optional EpisodeCollectionState episode_collection_state = 3; optional EpisodeOfflineState episode_offline_state = 4; - optional EpisodePlayState episode_play_state = 5; + 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; } @@ -43,6 +48,7 @@ message Response { 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"; } @@ -53,6 +59,9 @@ message AuxiliarySections { 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; } @@ -64,3 +73,7 @@ message ContinueListeningSection { 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 index ab0d1fe3..c9c3548a 100644 --- a/protocol/proto/show_show_state.proto +++ b/protocol/proto/show_show_state.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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 { diff --git a/protocol/proto/social_connect_v2.proto b/protocol/proto/social_connect_v2.proto index 265fbee6..f4d084c8 100644 --- a/protocol/proto/social_connect_v2.proto +++ b/protocol/proto/social_connect_v2.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -15,6 +15,16 @@ message Session { 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 { @@ -24,6 +34,8 @@ message SessionMember { string display_name = 4; string image_url = 5; string large_image_url = 6; + bool is_listening = 7; + bool is_controlling = 8; } message SessionUpdate { @@ -37,6 +49,13 @@ message DevicesExposure { 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; @@ -46,6 +65,9 @@ enum SessionUpdateReason { YOU_LEFT = 5; YOU_WERE_KICKED = 6; YOU_JOINED = 7; + PARTICIPANT_PROMOTED_TO_HOST = 8; + DISCOVERABILITY_CHANGED = 9; + USER_KICKED = 10; } enum DeviceExposureStatus { 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_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/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 index 8e813d25..abc8bd49 100644 --- a/protocol/proto/status_code.proto +++ b/protocol/proto/status_code.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -9,4 +9,6 @@ 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/stream_end_request.proto b/protocol/proto/stream_end_request.proto index 5ef8be7f..ed72fd51 100644 --- a/protocol/proto/stream_end_request.proto +++ b/protocol/proto/stream_end_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -6,13 +6,14 @@ package spotify.stream_reporting_esperanto.proto; import "stream_handle.proto"; import "play_reason.proto"; -import "play_source.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; - PlaySource source_end = 2; + string source_end = 2; PlayReason reason_end = 3; + MediaFormat format = 4; } diff --git a/protocol/proto/stream_prepare_request.proto b/protocol/proto/stream_prepare_request.proto deleted file mode 100644 index ce22e8eb..00000000 --- a/protocol/proto/stream_prepare_request.proto +++ /dev/null @@ -1,39 +0,0 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) - -syntax = "proto3"; - -package spotify.stream_reporting_esperanto.proto; - -import "play_reason.proto"; -import "play_source.proto"; -import "streaming_rule.proto"; - -option objc_class_prefix = "ESP"; -option java_package = "com.spotify.stream_reporting_esperanto.proto"; - -message StreamPrepareRequest { - 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; - PlaySource source_start = 19; - PlayReason reason_start = 20; - bool is_live = 22; - bool is_shuffle = 23; - bool is_offlined = 24; - bool is_incognito = 25; -} diff --git a/protocol/proto/stream_seek_request.proto b/protocol/proto/stream_seek_request.proto index 3736abf9..7d99169e 100644 --- a/protocol/proto/stream_seek_request.proto +++ b/protocol/proto/stream_seek_request.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -11,4 +11,6 @@ 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 index 3c4bfbb6..656016a6 100644 --- a/protocol/proto/stream_start_request.proto +++ b/protocol/proto/stream_start_request.proto @@ -1,20 +1,44 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; package spotify.stream_reporting_esperanto.proto; -import "format.proto"; import "media_type.proto"; -import "stream_handle.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 { - StreamHandle stream_handle = 1; - string media_id = 2; - MediaType media_type = 3; - Format format = 4; - uint64 playback_start_time = 5; + 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_prepare_response.proto b/protocol/proto/stream_start_response.proto similarity index 57% rename from protocol/proto/stream_prepare_response.proto rename to protocol/proto/stream_start_response.proto index 2f5a2c4e..98af2976 100644 --- a/protocol/proto/stream_prepare_response.proto +++ b/protocol/proto/stream_start_response.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -10,9 +10,7 @@ import "stream_handle.proto"; option objc_class_prefix = "ESP"; option java_package = "com.spotify.stream_reporting_esperanto.proto"; -message StreamPrepareResponse { - oneof response { - StatusResponse status = 1; - StreamHandle stream_handle = 2; - } +message StreamStartResponse { + StatusResponse status = 1; + StreamHandle stream_handle = 2; } diff --git a/protocol/proto/streaming_rule.proto b/protocol/proto/streaming_rule.proto index d72d7ca5..9593fdef 100644 --- a/protocol/proto/streaming_rule.proto +++ b/protocol/proto/streaming_rule.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -8,10 +8,9 @@ option objc_class_prefix = "ESP"; option java_package = "com.spotify.stream_reporting_esperanto.proto"; enum StreamingRule { - RULE_UNSET = 0; - RULE_NONE = 1; - RULE_DMCA_RADIO = 2; - RULE_PREVIEW = 3; - RULE_WIFI = 4; - RULE_SHUFFLE_MODE = 5; + 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/sync_request.proto b/protocol/proto/sync_request.proto index 090f8dce..b2d77625 100644 --- a/protocol/proto/sync_request.proto +++ b/protocol/proto/sync_request.proto @@ -1,9 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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"; diff --git a/protocol/proto/test_request_failure.proto b/protocol/proto/test_request_failure.proto deleted file mode 100644 index 036e38e1..00000000 --- a/protocol/proto/test_request_failure.proto +++ /dev/null @@ -1,14 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto2"; - -package spotify.image.proto; - -option optimize_for = CODE_SIZE; - -message TestRequestFailure { - optional string request = 1; - optional string source = 2; - optional string error = 3; - optional int64 result = 4; -} diff --git a/protocol/proto/track_offlining_cosmos_response.proto b/protocol/proto/track_offlining_cosmos_response.proto deleted file mode 100644 index bb650607..00000000 --- a/protocol/proto/track_offlining_cosmos_response.proto +++ /dev/null @@ -1,24 +0,0 @@ -// Extracted from: Spotify 1.1.33.569 (Windows) - -syntax = "proto2"; - -package spotify.track_offlining_cosmos.proto; - -option optimize_for = CODE_SIZE; - -message DecoratedTrack { - optional string uri = 1; - optional string title = 2; -} - -message ListResponse { - repeated string uri = 1; -} - -message DecorateResponse { - repeated DecoratedTrack tracks = 1; -} - -message StatusResponse { - optional bool offline = 1; -} diff --git a/protocol/proto/tts-resolve.proto b/protocol/proto/tts-resolve.proto index 89956843..adb50854 100644 --- a/protocol/proto/tts-resolve.proto +++ b/protocol/proto/tts-resolve.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -36,8 +36,12 @@ message ResolveRequest { 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; diff --git a/protocol/proto/unfinished_episodes_request.proto b/protocol/proto/unfinished_episodes_request.proto index 1e152bd6..68e5f903 100644 --- a/protocol/proto/unfinished_episodes_request.proto +++ b/protocol/proto/unfinished_episodes_request.proto @@ -1,19 +1,21 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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 show_cosmos.proto.EpisodePlayState episode_play_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 4; optional string link = 5; } 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/your_library_contains_request.proto b/protocol/proto/your_library_contains_request.proto index 33672bad..bbb43c20 100644 --- a/protocol/proto/your_library_contains_request.proto +++ b/protocol/proto/your_library_contains_request.proto @@ -1,11 +1,14 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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_decorate_request.proto b/protocol/proto/your_library_decorate_request.proto index e3fccc29..6b77a976 100644 --- a/protocol/proto/your_library_decorate_request.proto +++ b/protocol/proto/your_library_decorate_request.proto @@ -1,17 +1,14 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; package spotify.your_library.proto; -import "your_library_request.proto"; +import "your_library_pseudo_playlist_config.proto"; option optimize_for = CODE_SIZE; message YourLibraryDecorateRequest { repeated string requested_uri = 3; - YourLibraryLabelAndImage liked_songs_label_and_image = 201; - YourLibraryLabelAndImage your_episodes_label_and_image = 202; - YourLibraryLabelAndImage new_episodes_label_and_image = 203; - YourLibraryLabelAndImage local_files_label_and_image = 204; + YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 6; } diff --git a/protocol/proto/your_library_decorate_response.proto b/protocol/proto/your_library_decorate_response.proto index dab14203..125d5c33 100644 --- a/protocol/proto/your_library_decorate_response.proto +++ b/protocol/proto/your_library_decorate_response.proto @@ -1,10 +1,10 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; package spotify.your_library.proto; -import "your_library_response.proto"; +import "your_library_decorated_entity.proto"; option optimize_for = CODE_SIZE; @@ -14,6 +14,6 @@ message YourLibraryDecorateResponseHeader { message YourLibraryDecorateResponse { YourLibraryDecorateResponseHeader header = 1; - repeated YourLibraryResponseEntity entity = 2; + 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 index acb5afe7..897fc6c1 100644 --- a/protocol/proto/your_library_entity.proto +++ b/protocol/proto/your_library_entity.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -9,13 +9,24 @@ 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.CollectionAlbumEntry album = 2; - YourLibraryArtistEntity artist = 3; + collection.proto.CollectionAlbumLikeEntry album = 2; + collection.proto.CollectionArtistEntry artist = 3; YourLibraryRootlistEntity rootlist_entity = 4; - YourLibraryShowEntity show = 5; + YourLibraryShowWrapper show = 7; + YourLibraryBookWrapper book = 8; } } diff --git a/protocol/proto/your_library_index.proto b/protocol/proto/your_library_index.proto index 2d452dd5..835c0fa2 100644 --- a/protocol/proto/your_library_index.proto +++ b/protocol/proto/your_library_index.proto @@ -1,4 +1,4 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// Extracted from: Spotify 1.1.73.517 (macOS) syntax = "proto3"; @@ -6,18 +6,11 @@ package spotify.your_library.proto; option optimize_for = CODE_SIZE; -message YourLibraryArtistEntity { - string uri = 1; - string name = 2; - string image_uri = 3; - int64 add_time = 4; -} - message YourLibraryRootlistPlaylist { string image_uri = 1; - bool is_on_demand_in_free = 2; bool is_loading = 3; int32 rootlist_index = 4; + bool can_view = 5; } message YourLibraryRootlistFolder { @@ -48,13 +41,3 @@ message YourLibraryRootlistEntity { YourLibraryRootlistCollection collection = 7; } } - -message YourLibraryShowEntity { - string uri = 1; - string name = 2; - string creator_name = 3; - string image_uri = 4; - int64 add_time = 5; - bool is_music_and_talk = 6; - int64 publish_date = 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 index a75a0544..917a1add 100644 --- a/protocol/proto/your_library_request.proto +++ b/protocol/proto/your_library_request.proto @@ -1,74 +1,18 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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 YourLibraryRequestEntityInfo { - -} - -message YourLibraryRequestAlbumExtraInfo { - -} - -message YourLibraryRequestArtistExtraInfo { - -} - -message YourLibraryRequestPlaylistExtraInfo { - -} - -message YourLibraryRequestShowExtraInfo { - -} - -message YourLibraryRequestFolderExtraInfo { - -} - -message YourLibraryLabelAndImage { - string label = 1; - string image = 2; -} - -message YourLibraryRequestLikedSongsExtraInfo { - YourLibraryLabelAndImage label_and_image = 101; -} - -message YourLibraryRequestYourEpisodesExtraInfo { - YourLibraryLabelAndImage label_and_image = 101; -} - -message YourLibraryRequestNewEpisodesExtraInfo { - YourLibraryLabelAndImage label_and_image = 101; -} - -message YourLibraryRequestLocalFilesExtraInfo { - YourLibraryLabelAndImage label_and_image = 101; -} - -message YourLibraryRequestEntity { - YourLibraryRequestEntityInfo entityInfo = 1; - YourLibraryRequestAlbumExtraInfo album = 2; - YourLibraryRequestArtistExtraInfo artist = 3; - YourLibraryRequestPlaylistExtraInfo playlist = 4; - YourLibraryRequestShowExtraInfo show = 5; - YourLibraryRequestFolderExtraInfo folder = 6; - YourLibraryRequestLikedSongsExtraInfo liked_songs = 8; - YourLibraryRequestYourEpisodesExtraInfo your_episodes = 9; - YourLibraryRequestNewEpisodesExtraInfo new_episodes = 10; - YourLibraryRequestLocalFilesExtraInfo local_files = 11; -} - message YourLibraryRequestHeader { bool remaining_entities = 9; } message YourLibraryRequest { YourLibraryRequestHeader header = 1; - YourLibraryRequestEntity entity = 2; + YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 4; } diff --git a/protocol/proto/your_library_response.proto b/protocol/proto/your_library_response.proto index 124b35b4..c354ff5b 100644 --- a/protocol/proto/your_library_response.proto +++ b/protocol/proto/your_library_response.proto @@ -1,112 +1,23 @@ -// Extracted from: Spotify 1.1.61.583 (Windows) +// 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 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; - } -} - -message Offline { - enum Availability { - UNKNOWN = 0; - NO = 1; - YES = 2; - DOWNLOADING = 3; - WAITING = 4; - } -} - -message YourLibraryAlbumExtraInfo { - string artist_name = 1; - Offline.Availability offline_availability = 3; -} - -message YourLibraryArtistExtraInfo { - int32 num_tracks_in_collection = 1; -} - -message YourLibraryPlaylistExtraInfo { - string creator_name = 1; - Offline.Availability offline_availability = 3; - bool is_loading = 5; -} - -message YourLibraryShowExtraInfo { - string creator_name = 1; - Offline.Availability offline_availability = 3; - int64 publish_date = 4; - bool is_music_and_talk = 5; -} - -message YourLibraryFolderExtraInfo { - int32 number_of_playlists = 2; - int32 number_of_folders = 3; -} - -message YourLibraryLikedSongsExtraInfo { - Offline.Availability offline_availability = 2; - int32 number_of_songs = 3; -} - -message YourLibraryYourEpisodesExtraInfo { - Offline.Availability offline_availability = 2; - int32 number_of_episodes = 3; -} - -message YourLibraryNewEpisodesExtraInfo { - int64 publish_date = 1; -} - -message YourLibraryLocalFilesExtraInfo { - int32 number_of_files = 1; -} - -message YourLibraryResponseEntity { - YourLibraryEntityInfo entityInfo = 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; - } -} - message YourLibraryResponseHeader { - bool has_albums = 1; - bool has_artists = 2; - bool has_playlists = 3; - bool has_shows = 4; - bool has_downloaded_albums = 5; - bool has_downloaded_artists = 6; - bool has_downloaded_playlists = 7; - bool has_downloaded_shows = 8; 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 YourLibraryResponseEntity entity = 2; + repeated YourLibraryDecoratedEntity entity = 2; string error = 99; } diff --git a/publish.sh b/publish.sh index 478741a5..fb4a475a 100755 --- a/publish.sh +++ b/publish.sh @@ -6,7 +6,7 @@ DRY_RUN='false' WORKINGDIR="$( cd "$(dirname "$0")" ; pwd -P )" cd $WORKINGDIR -crates=( "protocol" "core" "audio" "metadata" "playback" "connect" "librespot" ) +crates=( "protocol" "core" "discovery" "audio" "metadata" "playback" "connect" "librespot" ) function switchBranch { if [ "$SKIP_MERGE" = 'false' ] ; then 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 a3687aaa..0dc25408 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,50 +1,63 @@ +use std::{ + env, + fs::create_dir_all, + ops::RangeInclusive, + path::{Path, PathBuf}, + pin::Pin, + process::exit, + str::FromStr, + time::{Duration, Instant}, +}; + use futures_util::{future, FutureExt, StreamExt}; use librespot_playback::player::PlayerEvent; -use log::{error, info, warn}; +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::spirc::Spirc, + core::{ + authentication::Credentials, + cache::Cache, + config::{ConnectConfig, DeviceType}, + version, Session, SessionConfig, + }, + playback::{ + audio_backend::{self, SinkBuilder, BACKENDS}, + config::{ + AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl, + }, + dither, + mixer::{self, MixerConfig, MixerFn}, + player::{db_to_ratio, ratio_to_db, Player}, + }, }; -use librespot::playback::dither; + #[cfg(feature = "alsa-backend")] use librespot::playback::mixer::alsamixer::AlsaMixer; -use librespot::playback::mixer::mappings::MappedCtrl; -use librespot::playback::mixer::{self, MixerConfig, MixerFn}; -use librespot::playback::player::{db_to_ratio, Player}; mod player_event_handler; use player_event_handler::{emit_sink_event, run_program_on_events}; -use std::env; -use std::io::{stderr, Write}; -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())) } fn usage(program: &str, opts: &getopts::Options) -> String { - let brief = format!("Usage: {} [options]", program); + let repo_home = env!("CARGO_PKG_REPOSITORY"); + let desc = env!("CARGO_PKG_DESCRIPTION"); + let version = get_version_string(); + let brief = format!( + "{}\n\n{}\n\n{}\n\nUsage: {} []", + version, desc, repo_home, program + ); opts.usage(&brief) } -fn setup_logging(verbose: bool) { +fn setup_logging(quiet: bool, verbose: bool) { let mut builder = env_logger::Builder::new(); match env::var("RUST_LOG") { Ok(config) => { @@ -53,21 +66,29 @@ fn setup_logging(verbose: bool) { if verbose { warn!("`--verbose` flag overidden by `RUST_LOG` environment variable"); + } else if quiet { + warn!("`--quiet` flag overidden by `RUST_LOG` environment variable"); } } Err(_) => { if verbose { builder.parse_filters("libmdns=info,librespot=trace"); + } else if quiet { + builder.parse_filters("libmdns=warn,librespot=warn"); } else { builder.parse_filters("libmdns=info,librespot=info"); } builder.init(); + + if verbose && quiet { + warn!("`--verbose` and `--quiet` are mutually exclusive. Logging can not be both verbose and quiet. Using verbose mode."); + } } } } fn list_backends() { - println!("Available backends : "); + println!("Available backends: "); for (&(name, _), idx) in BACKENDS.iter().zip(0..) { if idx == 0 { println!("- {} (default)", name); @@ -77,29 +98,6 @@ fn list_backends() { } } -pub fn get_credentials Option>( - username: Option, - password: Option, - cached_credentials: Option, - prompt: F, -) -> Option { - if let Some(username) = username { - if let Some(password) = password { - return Some(Credentials::with_password(username, password)); - } - - match cached_credentials { - Some(credentials) if username == credentials.username => Some(credentials), - _ => { - let password = prompt(&username)?; - Some(Credentials::with_password(username, password)) - } - } - } else { - cached_credentials - } -} - #[derive(Debug, Error)] pub enum ParseFileSizeError { #[error("empty argument")] @@ -160,14 +158,20 @@ pub fn parse_file_size(input: &str) -> Result { Ok((num * base.pow(exponent) as f64) as u64) } -fn print_version() { - println!( - "librespot {semver} {sha} (Built on {build_date}, Build ID: {build_id})", +fn get_version_string() -> String { + #[cfg(debug_assertions)] + const BUILD_PROFILE: &str = "debug"; + #[cfg(not(debug_assertions))] + const BUILD_PROFILE: &str = "release"; + + format!( + "librespot {semver} {sha} (Built on {build_date}, Build ID: {build_id}, Profile: {build_profile})", semver = version::SEMVER, sha = version::SHA_SHORT, build_date = version::BUILD_DATE, - build_id = version::BUILD_ID - ); + build_id = version::BUILD_ID, + build_profile = BUILD_PROFILE + ) } struct Setup { @@ -187,27 +191,37 @@ struct Setup { emit_sink_events: bool, } -fn get_setup(args: &[String]) -> Setup { +fn get_setup() -> Setup { + const VALID_INITIAL_VOLUME_RANGE: RangeInclusive = 0..=100; + const VALID_VOLUME_RANGE: RangeInclusive = 0.0..=100.0; + const VALID_NORMALISATION_KNEE_RANGE: RangeInclusive = 0.0..=2.0; + const VALID_NORMALISATION_PREGAIN_RANGE: RangeInclusive = -10.0..=10.0; + const VALID_NORMALISATION_THRESHOLD_RANGE: RangeInclusive = -10.0..=0.0; + const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive = 1..=500; + 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 = "b"; - const CACHE: &str = "c"; + const BITRATE: &str = "bitrate"; + const CACHE: &str = "cache"; const CACHE_SIZE_LIMIT: &str = "cache-size-limit"; const DEVICE: &str = "device"; const DEVICE_TYPE: &str = "device-type"; const DISABLE_AUDIO_CACHE: &str = "disable-audio-cache"; + const DISABLE_CREDENTIAL_CACHE: &str = "disable-credential-cache"; const DISABLE_DISCOVERY: &str = "disable-discovery"; const DISABLE_GAPLESS: &str = "disable-gapless"; const DITHER: &str = "dither"; const EMIT_SINK_EVENTS: &str = "emit-sink-events"; const ENABLE_VOLUME_NORMALISATION: &str = "enable-volume-normalisation"; const FORMAT: &str = "format"; - const HELP: &str = "h"; + const HELP: &str = "help"; const INITIAL_VOLUME: &str = "initial-volume"; - const MIXER_CARD: &str = "mixer-card"; - const MIXER_INDEX: &str = "mixer-index"; - const MIXER_NAME: &str = "mixer-name"; + const MIXER_TYPE: &str = "mixer"; + const ALSA_MIXER_DEVICE: &str = "alsa-mixer-device"; + const ALSA_MIXER_INDEX: &str = "alsa-mixer-index"; + const ALSA_MIXER_CONTROL: &str = "alsa-mixer-control"; const NAME: &str = "name"; const NORMALISATION_ATTACK: &str = "normalisation-attack"; const NORMALISATION_GAIN_TYPE: &str = "normalisation-gain-type"; @@ -220,7 +234,9 @@ fn get_setup(args: &[String]) -> Setup { 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"; @@ -228,315 +244,732 @@ fn get_setup(args: &[String]) -> Setup { const VOLUME_RANGE: &str = "volume-range"; 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"; + const SYSTEM_CACHE_SHORT: &str = "C"; + const CACHE_SHORT: &str = "c"; + const DITHER_SHORT: &str = "D"; + const DEVICE_SHORT: &str = "d"; + const VOLUME_CTRL_SHORT: &str = "E"; + const VOLUME_RANGE_SHORT: &str = "e"; + const DEVICE_TYPE_SHORT: &str = "F"; + const FORMAT_SHORT: &str = "f"; + const DISABLE_AUDIO_CACHE_SHORT: &str = "G"; + const DISABLE_GAPLESS_SHORT: &str = "g"; + const DISABLE_CREDENTIAL_CACHE_SHORT: &str = "H"; + const HELP_SHORT: &str = "h"; + const CACHE_SIZE_LIMIT_SHORT: &str = "M"; + const MIXER_TYPE_SHORT: &str = "m"; + const ENABLE_VOLUME_NORMALISATION_SHORT: &str = "N"; + const NAME_SHORT: &str = "n"; + const DISABLE_DISCOVERY_SHORT: &str = "O"; + const ONEVENT_SHORT: &str = "o"; + const PASSTHROUGH_SHORT: &str = "P"; + const PASSWORD_SHORT: &str = "p"; + const EMIT_SINK_EVENTS_SHORT: &str = "Q"; + const QUIET_SHORT: &str = "q"; + const INITIAL_VOLUME_SHORT: &str = "R"; + 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"; + const VERBOSE_SHORT: &str = "v"; + const NORMALISATION_GAIN_TYPE_SHORT: &str = "W"; + const NORMALISATION_KNEE_SHORT: &str = "w"; + const NORMALISATION_METHOD_SHORT: &str = "X"; + const PROXY_SHORT: &str = "x"; + const NORMALISATION_PREGAIN_SHORT: &str = "Y"; + const NORMALISATION_RELEASE_SHORT: &str = "y"; + const NORMALISATION_THRESHOLD_SHORT: &str = "Z"; + const ZEROCONF_PORT_SHORT: &str = "z"; + + // 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."; + #[cfg(not(feature = "alsa-backend"))] + const MIXER_TYPE_DESC: &str = "Not supported by the included audio backend(s)."; + #[cfg(any( + feature = "alsa-backend", + feature = "rodio-backend", + feature = "portaudio-backend" + ))] + const DEVICE_DESC: &str = "Audio device to use. Use ? to list options if using alsa, portaudio or rodio. Defaults to the backend's default."; + #[cfg(not(any( + feature = "alsa-backend", + feature = "rodio-backend", + feature = "portaudio-backend" + )))] + const DEVICE_DESC: &str = "Not supported by the included audio backend(s)."; + #[cfg(feature = "alsa-backend")] + const ALSA_MIXER_CONTROL_DESC: &str = + "Alsa mixer control, e.g. PCM, Master or similar. Defaults to PCM."; + #[cfg(not(feature = "alsa-backend"))] + const ALSA_MIXER_CONTROL_DESC: &str = "Not supported by the included audio backend(s)."; + #[cfg(feature = "alsa-backend")] + const ALSA_MIXER_DEVICE_DESC: &str = "Alsa mixer device, e.g hw:0 or similar from `aplay -l`. Defaults to `--device` if specified, default otherwise."; + #[cfg(not(feature = "alsa-backend"))] + const ALSA_MIXER_DEVICE_DESC: &str = "Not supported by the included audio backend(s)."; + #[cfg(feature = "alsa-backend")] + const ALSA_MIXER_INDEX_DESC: &str = "Alsa index of the cards mixer. Defaults to 0."; + #[cfg(not(feature = "alsa-backend"))] + const ALSA_MIXER_INDEX_DESC: &str = "Not supported by the included audio backend(s)."; + #[cfg(feature = "alsa-backend")] + const INITIAL_VOLUME_DESC: &str = "Initial volume in % from 0 - 100. Default for softvol: 50. For the alsa mixer: the current volume."; + #[cfg(not(feature = "alsa-backend"))] + const INITIAL_VOLUME_DESC: &str = "Initial volume in % from 0 - 100. Defaults to 50."; + #[cfg(feature = "alsa-backend")] + const VOLUME_RANGE_DESC: &str = "Range of the volume control (dB) from 0.0 to 100.0. Default for softvol: 60.0. For the alsa mixer: what the control supports."; + #[cfg(not(feature = "alsa-backend"))] + const VOLUME_RANGE_DESC: &str = + "Range of the volume control (dB) from 0.0 to 100.0. Defaults to 60.0."; + let mut opts = getopts::Options::new(); opts.optflag( + HELP_SHORT, HELP, - "help", "Print this help menu.", - ).optopt( - CACHE, - "cache", - "Path to a directory where files will be cached.", - "PATH", - ).optopt( - "", - SYSTEM_CACHE, - "Path to a directory where system files (credentials, volume) will be cached. Can be different from cache option value.", - "PATH", - ).optopt( - "", - CACHE_SIZE_LIMIT, - "Limits the size of the cache for audio files.", - "SIZE" - ).optflag("", DISABLE_AUDIO_CACHE, "Disable caching of the audio data.") - .optopt("n", NAME, "Device name.", "NAME") - .optopt("", DEVICE_TYPE, "Displayed device type.", "TYPE") + ) + .optflag( + VERSION_SHORT, + VERSION, + "Display librespot version string.", + ) + .optflag( + VERBOSE_SHORT, + VERBOSE, + "Enable verbose log output.", + ) + .optflag( + QUIET_SHORT, + QUIET, + "Only log warning and error messages.", + ) + .optflag( + DISABLE_AUDIO_CACHE_SHORT, + DISABLE_AUDIO_CACHE, + "Disable caching of the audio data.", + ) + .optflag( + DISABLE_CREDENTIAL_CACHE_SHORT, + DISABLE_CREDENTIAL_CACHE, + "Disable caching of credentials.", + ) + .optflag( + DISABLE_DISCOVERY_SHORT, + DISABLE_DISCOVERY, + "Disable zeroconf discovery mode.", + ) + .optflag( + DISABLE_GAPLESS_SHORT, + DISABLE_GAPLESS, + "Disable gapless playback.", + ) + .optflag( + EMIT_SINK_EVENTS_SHORT, + 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, + "Play all tracks at approximately the same apparent volume.", + ) .optopt( + NAME_SHORT, + NAME, + "Device name. Defaults to Librespot.", + "NAME", + ) + .optopt( + BITRATE_SHORT, BITRATE, - "bitrate", "Bitrate (kbps) {96|160|320}. Defaults to 160.", "BITRATE", ) .optopt( - "", - ONEVENT, - "Run PROGRAM when a playback event occurs.", - "PROGRAM", - ) - .optflag("", EMIT_SINK_EVENTS, "Run program set by --onevent before sink is opened and after it is closed.") - .optflag("v", VERBOSE, "Enable verbose output.") - .optflag("V", VERSION, "Display librespot version string.") - .optopt("u", USERNAME, "Username to sign in with.", "USERNAME") - .optopt("p", PASSWORD, "Password", "PASSWORD") - .optopt("", PROXY, "HTTP proxy to use when connecting.", "URL") - .optopt("", AP_PORT, "Connect to AP with specified port. If no AP with that port are present fallback AP will be used. Available ports are usually 80, 443 and 4070.", "PORT") - .optflag("", DISABLE_DISCOVERY, "Disable discovery mode.") - .optopt( - "", - BACKEND, - "Audio backend to use. Use '?' to list options.", - "NAME", - ) - .optopt( - "", - DEVICE, - "Audio device to use. Use '?' to list options if using alsa, portaudio or rodio.", - "NAME", - ) - .optopt( - "", + FORMAT_SHORT, FORMAT, "Output format {F64|F32|S32|S24|S24_3|S16}. Defaults to S16.", "FORMAT", ) .optopt( - "", + DITHER_SHORT, DITHER, - "Specify the dither algorithm to use - [none, gpdf, tpdf, tpdf_hp]. Defaults to 'tpdf' for formats S16, S24, S24_3 and 'none' for other formats.", + "Specify the dither algorithm to use {none|gpdf|tpdf|tpdf_hp}. Defaults to tpdf for formats S16, S24, S24_3 and none for other formats.", "DITHER", ) - .optopt("", "mixer", "Mixer to use {alsa|softvol}.", "MIXER") .optopt( - "m", - MIXER_NAME, - "Alsa mixer control, e.g. 'PCM' or 'Master'. Defaults to 'PCM'.", + DEVICE_TYPE_SHORT, + DEVICE_TYPE, + "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 after downloading.", + "PATH", + ) + .optopt( + SYSTEM_CACHE_SHORT, + SYSTEM_CACHE, + "Path to a directory where system files (credentials, volume) will be cached. May be different from the `--cache` option value.", + "PATH", + ) + .optopt( + CACHE_SIZE_LIMIT_SHORT, + CACHE_SIZE_LIMIT, + "Limits the size of the cache for audio files. It's possible to use suffixes like K, M or G, e.g. 16G for example.", + "SIZE" + ) + .optopt( + BACKEND_SHORT, + BACKEND, + "Audio backend to use. Use ? to list options.", "NAME", ) .optopt( - "", - MIXER_CARD, - "Alsa mixer card, e.g 'hw:0' or similar from `aplay -l`. Defaults to DEVICE if specified, 'default' otherwise.", - "MIXER_CARD", + USERNAME_SHORT, + USERNAME, + "Username used to sign in with.", + "USERNAME", ) .optopt( - "", - MIXER_INDEX, - "Alsa index of the cards mixer. Defaults to 0.", - "INDEX", + PASSWORD_SHORT, + PASSWORD, + "Password used to sign in with.", + "PASSWORD", ) .optopt( - "", + ONEVENT_SHORT, + ONEVENT, + "Run PROGRAM when a playback event occurs.", + "PROGRAM", + ) + .optopt( + ALSA_MIXER_CONTROL_SHORT, + ALSA_MIXER_CONTROL, + ALSA_MIXER_CONTROL_DESC, + "NAME", + ) + .optopt( + ALSA_MIXER_DEVICE_SHORT, + ALSA_MIXER_DEVICE, + ALSA_MIXER_DEVICE_DESC, + "DEVICE", + ) + .optopt( + ALSA_MIXER_INDEX_SHORT, + ALSA_MIXER_INDEX, + ALSA_MIXER_INDEX_DESC, + "NUMBER", + ) + .optopt( + MIXER_TYPE_SHORT, + MIXER_TYPE, + MIXER_TYPE_DESC, + "MIXER", + ) + .optopt( + DEVICE_SHORT, + DEVICE, + DEVICE_DESC, + "NAME", + ) + .optopt( + INITIAL_VOLUME_SHORT, INITIAL_VOLUME, - "Initial volume in % from 0-100. Default for softvol: '50'. For the Alsa mixer: the current volume.", + INITIAL_VOLUME_DESC, "VOLUME", ) .optopt( - "", - ZEROCONF_PORT, - "The port the internal server advertised over zeroconf uses.", - "PORT", - ) - .optflag( - "", - ENABLE_VOLUME_NORMALISATION, - "Play all tracks at the same volume.", + VOLUME_CTRL_SHORT, + VOLUME_CTRL, + "Volume control scale type {cubic|fixed|linear|log}. Defaults to log.", + "VOLUME_CTRL" ) .optopt( - "", + VOLUME_RANGE_SHORT, + VOLUME_RANGE, + VOLUME_RANGE_DESC, + "RANGE", + ) + .optopt( + NORMALISATION_METHOD_SHORT, NORMALISATION_METHOD, "Specify the normalisation method to use {basic|dynamic}. Defaults to dynamic.", "METHOD", ) .optopt( - "", + NORMALISATION_GAIN_TYPE_SHORT, NORMALISATION_GAIN_TYPE, - "Specify the normalisation gain type to use {track|album}. Defaults to album.", + "Specify the normalisation gain type to use {track|album|auto}. Defaults to auto.", "TYPE", ) .optopt( - "", + NORMALISATION_PREGAIN_SHORT, NORMALISATION_PREGAIN, - "Pregain (dB) applied by volume normalisation. Defaults to 0.", + "Pregain (dB) applied by volume normalisation from -10.0 to 10.0. Defaults to 0.0.", "PREGAIN", ) .optopt( - "", + NORMALISATION_THRESHOLD_SHORT, NORMALISATION_THRESHOLD, - "Threshold (dBFS) to prevent clipping. Defaults to -1.0.", + "Threshold (dBFS) at which point the dynamic limiter engages to prevent clipping from 0.0 to -10.0. Defaults to -2.0.", "THRESHOLD", ) .optopt( - "", + NORMALISATION_ATTACK_SHORT, NORMALISATION_ATTACK, - "Attack time (ms) in which the dynamic limiter is reducing gain. Defaults to 5.", + "Attack time (ms) in which the dynamic limiter reduces gain from 1 to 500. Defaults to 5.", "TIME", ) .optopt( - "", + NORMALISATION_RELEASE_SHORT, NORMALISATION_RELEASE, - "Release or decay time (ms) in which the dynamic limiter is restoring gain. Defaults to 100.", + "Release or decay time (ms) in which the dynamic limiter restores gain from 1 to 1000. Defaults to 100.", "TIME", ) .optopt( - "", + NORMALISATION_KNEE_SHORT, NORMALISATION_KNEE, - "Knee steepness of the dynamic limiter. Defaults to 1.0.", + "Knee steepness of the dynamic limiter from 0.0 to 2.0. Defaults to 1.0.", "KNEE", ) .optopt( - "", - VOLUME_CTRL, - "Volume control type {cubic|fixed|linear|log}. Defaults to log.", - "VOLUME_CTRL" + ZEROCONF_PORT_SHORT, + ZEROCONF_PORT, + "The port the internal server advertises over zeroconf 1 - 65535. Ports <= 1024 may require root privileges.", + "PORT", ) .optopt( - "", - VOLUME_RANGE, - "Range of the volume control (dB). Default for softvol: 60. For the Alsa mixer: what the control supports.", - "RANGE", + PROXY_SHORT, + PROXY, + "HTTP proxy to use when connecting.", + "URL", ) - .optflag( - "", - AUTOPLAY, - "Automatically play similar songs when your music ends.", - ) - .optflag( - "", - DISABLE_GAPLESS, - "Disable gapless playback.", - ) - .optflag( - "", - PASSTHROUGH, - "Pass raw stream to output, only works for pipe and subprocess.", + .optopt( + AP_PORT_SHORT, + AP_PORT, + "Connect to an AP with a specified port 1 - 65535. If no AP with that port is present a fallback AP will be used. Available ports are usually 80, 443 and 4070.", + "PORT", ); + let args: Vec<_> = std::env::args_os() + .filter_map(|s| match s.into_string() { + Ok(valid) => Some(valid), + Err(s) => { + eprintln!( + "Command line argument was not valid Unicode and will not be evaluated: {:?}", + s + ); + None + } + }) + .collect(); + let matches = match opts.parse(&args[1..]) { Ok(m) => m, - Err(f) => { - eprintln!( - "Error parsing command line options: {}\n{}", - f, - usage(&args[0], &opts) - ); + Err(e) => { + eprintln!("Error parsing command line options: {}", e); + println!("\n{}", usage(&args[0], &opts)); exit(1); } }; - if matches.opt_present(HELP) { + let stripped_env_key = |k: &str| { + k.trim_start_matches("LIBRESPOT_") + .replace("_", "-") + .to_lowercase() + }; + + let env_vars: Vec<_> = env::vars_os().filter_map(|(k, v)| match k.into_string() { + Ok(key) if key.starts_with("LIBRESPOT_") => { + let stripped_key = stripped_env_key(&key); + // We only care about long option/flag names. + if stripped_key.chars().count() > 1 && matches.opt_defined(&stripped_key) { + match v.into_string() { + Ok(value) => Some((key, value)), + Err(s) => { + eprintln!("Environment variable was not valid Unicode and will not be evaluated: {}={:?}", key, s); + None + } + } + } else { + None + } + }, + _ => None + }) + .collect(); + + let opt_present = + |opt| matches.opt_present(opt) || env_vars.iter().any(|(k, _)| stripped_env_key(k) == opt); + + let opt_str = |opt| { + if matches.opt_present(opt) { + matches.opt_str(opt) + } else { + env_vars + .iter() + .find(|(k, _)| stripped_env_key(k) == opt) + .map(|(_, v)| v.to_string()) + } + }; + + if opt_present(HELP) { println!("{}", usage(&args[0], &opts)); exit(0); } - if matches.opt_present(VERSION) { - print_version(); + if opt_present(VERSION) { + println!("{}", get_version_string()); exit(0); } - let verbose = matches.opt_present(VERBOSE); - setup_logging(verbose); + setup_logging(opt_present(QUIET), opt_present(VERBOSE)); - info!( - "librespot {semver} {sha} (Built on {build_date}, Build ID: {build_id})", - semver = version::SEMVER, - sha = version::SHA_SHORT, - build_date = version::BUILD_DATE, - build_id = version::BUILD_ID - ); + info!("{}", get_version_string()); - let backend_name = matches.opt_str(BACKEND); + if !env_vars.is_empty() { + trace!("Environment variable(s):"); + + for (k, v) in &env_vars { + if matches!(k.as_str(), "LIBRESPOT_PASSWORD" | "LIBRESPOT_USERNAME") { + trace!("\t\t{}=\"XXXXXXXX\"", k); + } else if v.is_empty() { + trace!("\t\t{}=", k); + } else { + trace!("\t\t{}=\"{}\"", k, v); + } + } + } + + let args_len = args.len(); + + if args_len > 1 { + trace!("Command line argument(s):"); + + for (index, key) in args.iter().enumerate() { + let opt = key.trim_start_matches('-'); + + if index > 0 + && &args[index - 1] != key + && matches.opt_defined(opt) + && matches.opt_present(opt) + { + if matches!(opt, PASSWORD | PASSWORD_SHORT | USERNAME | USERNAME_SHORT) { + // Don't log creds. + trace!("\t\t{} \"XXXXXXXX\"", key); + } else { + let value = matches.opt_str(opt).unwrap_or_else(|| "".to_string()); + if value.is_empty() { + trace!("\t\t{}", key); + } else { + trace!("\t\t{} \"{}\"", key, value); + } + } + } + } + } + + #[cfg(not(feature = "alsa-backend"))] + for a in &[ + MIXER_TYPE, + ALSA_MIXER_DEVICE, + ALSA_MIXER_INDEX, + ALSA_MIXER_CONTROL, + ] { + if opt_present(a) { + warn!("Alsa specific options have no effect if the alsa backend is not enabled at build time."); + break; + } + } + + let backend_name = opt_str(BACKEND); if backend_name == Some("?".into()) { list_backends(); exit(0); } - let backend = audio_backend::find(backend_name).expect("Invalid backend"); + let invalid_error_msg = + |long: &str, short: &str, invalid: &str, valid_values: &str, default_value: &str| { + error!("Invalid `--{}` / `-{}`: \"{}\"", long, short, invalid); - let format = matches - .opt_str(FORMAT) + if !valid_values.is_empty() { + println!("Valid `--{}` / `-{}` values: {}", long, short, valid_values); + } + + if !default_value.is_empty() { + println!("Default: {}", default_value); + } + }; + + let empty_string_error_msg = |long: &str, short: &str| { + error!("`--{}` / `-{}` can not be an empty string", long, short); + exit(1); + }; + + let backend = audio_backend::find(backend_name).unwrap_or_else(|| { + invalid_error_msg( + BACKEND, + BACKEND_SHORT, + &opt_str(BACKEND).unwrap_or_default(), + "", + "", + ); + + list_backends(); + exit(1); + }); + + let format = opt_str(FORMAT) .as_deref() - .map(|format| AudioFormat::from_str(format).expect("Invalid output format")) + .map(|format| { + AudioFormat::from_str(format).unwrap_or_else(|_| { + let default_value = &format!("{:?}", AudioFormat::default()); + invalid_error_msg( + FORMAT, + FORMAT_SHORT, + format, + "F64, F32, S32, S24, S24_3, S16", + default_value, + ); + + exit(1); + }) + }) .unwrap_or_default(); - let device = matches.opt_str(DEVICE); - if device == Some("?".into()) { - backend(device, format); - exit(0); + #[cfg(any( + feature = "alsa-backend", + feature = "rodio-backend", + feature = "portaudio-backend" + ))] + let device = opt_str(DEVICE); + + #[cfg(any( + feature = "alsa-backend", + feature = "rodio-backend", + feature = "portaudio-backend" + ))] + if let Some(ref value) = device { + if value == "?" { + backend(device, format); + exit(0); + } else if value.is_empty() { + empty_string_error_msg(DEVICE, DEVICE_SHORT); + } } - let mixer_name = matches.opt_str(MIXER_NAME); - let mixer = mixer::find(mixer_name.as_deref()).expect("Invalid mixer"); + #[cfg(not(any( + feature = "alsa-backend", + feature = "rodio-backend", + feature = "portaudio-backend" + )))] + let device: Option = None; + + #[cfg(not(any( + feature = "alsa-backend", + feature = "rodio-backend", + feature = "portaudio-backend" + )))] + if opt_present(DEVICE) { + warn!( + "The `--{}` / `-{}` option is not supported by the included audio backend(s), and has no effect.", + DEVICE, DEVICE_SHORT, + ); + } + + #[cfg(feature = "alsa-backend")] + let mixer_type = opt_str(MIXER_TYPE); + #[cfg(not(feature = "alsa-backend"))] + let mixer_type: Option = None; + + let mixer = mixer::find(mixer_type.as_deref()).unwrap_or_else(|| { + invalid_error_msg( + MIXER_TYPE, + MIXER_TYPE_SHORT, + &opt_str(MIXER_TYPE).unwrap_or_default(), + "alsa, softvol", + "softvol", + ); + + exit(1); + }); let mixer_config = { - let card = matches.opt_str(MIXER_CARD).unwrap_or_else(|| { + let mixer_default_config = MixerConfig::default(); + + #[cfg(feature = "alsa-backend")] + let device = opt_str(ALSA_MIXER_DEVICE).unwrap_or_else(|| { if let Some(ref device_name) = device { device_name.to_string() } else { - MixerConfig::default().card + mixer_default_config.device.clone() } }); - let index = matches - .opt_str(MIXER_INDEX) - .map(|index| index.parse::().unwrap()) - .unwrap_or(0); - let control = matches - .opt_str(MIXER_NAME) - .unwrap_or_else(|| MixerConfig::default().control); - let mut volume_range = matches - .opt_str(VOLUME_RANGE) - .map(|range| range.parse::().unwrap()) - .unwrap_or_else(|| match mixer_name.as_deref() { + + #[cfg(not(feature = "alsa-backend"))] + let device = mixer_default_config.device; + + #[cfg(feature = "alsa-backend")] + let index = opt_str(ALSA_MIXER_INDEX) + .map(|index| { + index.parse::().unwrap_or_else(|_| { + invalid_error_msg( + ALSA_MIXER_INDEX, + ALSA_MIXER_INDEX_SHORT, + &index, + "", + &mixer_default_config.index.to_string(), + ); + + exit(1); + }) + }) + .unwrap_or_else(|| mixer_default_config.index); + + #[cfg(not(feature = "alsa-backend"))] + let index = mixer_default_config.index; + + #[cfg(feature = "alsa-backend")] + let control = opt_str(ALSA_MIXER_CONTROL).unwrap_or(mixer_default_config.control); + + #[cfg(feature = "alsa-backend")] + if control.is_empty() { + empty_string_error_msg(ALSA_MIXER_CONTROL, ALSA_MIXER_CONTROL_SHORT); + } + + #[cfg(not(feature = "alsa-backend"))] + let control = mixer_default_config.control; + + let volume_range = opt_str(VOLUME_RANGE) + .map(|range| match range.parse::() { + Ok(value) if (VALID_VOLUME_RANGE).contains(&value) => value, + _ => { + let valid_values = &format!( + "{} - {}", + VALID_VOLUME_RANGE.start(), + VALID_VOLUME_RANGE.end() + ); + + #[cfg(feature = "alsa-backend")] + let default_value = &format!( + "softvol - {}, alsa - what the control supports", + VolumeCtrl::DEFAULT_DB_RANGE + ); + + #[cfg(not(feature = "alsa-backend"))] + let default_value = &VolumeCtrl::DEFAULT_DB_RANGE.to_string(); + + invalid_error_msg( + VOLUME_RANGE, + VOLUME_RANGE_SHORT, + &range, + valid_values, + default_value, + ); + + exit(1); + } + }) + .unwrap_or_else(|| match mixer_type.as_deref() { #[cfg(feature = "alsa-backend")] - Some(AlsaMixer::NAME) => 0.0, // let Alsa query the control + Some(AlsaMixer::NAME) => 0.0, // let alsa query the control _ => VolumeCtrl::DEFAULT_DB_RANGE, }); - if volume_range < 0.0 { - // User might have specified range as minimum dB volume. - volume_range = -volume_range; - warn!( - "Please enter positive volume ranges only, assuming {:.2} dB", - volume_range - ); - } - let volume_ctrl = matches - .opt_str(VOLUME_CTRL) + + let volume_ctrl = opt_str(VOLUME_CTRL) .as_deref() .map(|volume_ctrl| { - VolumeCtrl::from_str_with_range(volume_ctrl, volume_range) - .expect("Invalid volume control type") + VolumeCtrl::from_str_with_range(volume_ctrl, volume_range).unwrap_or_else(|_| { + invalid_error_msg( + VOLUME_CTRL, + VOLUME_CTRL_SHORT, + volume_ctrl, + "cubic, fixed, linear, log", + "log", + ); + + exit(1); + }) }) - .unwrap_or_else(|| { - let mut volume_ctrl = VolumeCtrl::default(); - volume_ctrl.set_db_range(volume_range); - volume_ctrl - }); + .unwrap_or_else(|| VolumeCtrl::Log(volume_range)); MixerConfig { - card, + device, control, index, volume_ctrl, } }; - let cache = { - let audio_dir; - let system_dir; - if matches.opt_present(DISABLE_AUDIO_CACHE) { - audio_dir = None; - system_dir = matches - .opt_str(SYSTEM_CACHE) - .or_else(|| matches.opt_str(CACHE)) - .map(|p| p.into()); - } else { - let cache_dir = matches.opt_str(CACHE); - audio_dir = cache_dir - .as_ref() - .map(|p| AsRef::::as_ref(p).join("files")); - system_dir = matches - .opt_str(SYSTEM_CACHE) - .or(cache_dir) - .map(|p| p.into()); + 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)) + .map(|p| p.into()); + + let cred_dir = if opt_present(DISABLE_CREDENTIAL_CACHE) { + None + } else { + volume_dir.clone() + }; + + let audio_dir = if opt_present(DISABLE_AUDIO_CACHE) { + None + } else { + opt_str(CACHE) + .as_ref() + .map(|p| AsRef::::as_ref(p).join("files")) + }; let limit = if audio_dir.is_some() { - matches - .opt_str(CACHE_SIZE_LIMIT) + opt_str(CACHE_SIZE_LIMIT) .as_deref() .map(parse_file_size) .map(|e| { e.unwrap_or_else(|e| { - eprintln!("Invalid argument passed as cache size limit: {}", e); + invalid_error_msg( + CACHE_SIZE_LIMIT, + CACHE_SIZE_LIMIT_SHORT, + &e.to_string(), + "", + "", + ); + exit(1); }) }) @@ -544,7 +977,14 @@ fn get_setup(args: &[String]) -> Setup { None }; - match Cache::new(system_dir, audio_dir, limit) { + if audio_dir.is_none() && opt_present(CACHE_SIZE_LIMIT) { + warn!( + "Without a `--{}` / `-{}` path, and/or if the `--{}` / `-{}` flag is set, `--{}` / `-{}` has no effect.", + CACHE, CACHE_SHORT, DISABLE_AUDIO_CACHE, DISABLE_AUDIO_CACHE_SHORT, CACHE_SIZE_LIMIT, CACHE_SIZE_LIMIT_SHORT + ); + } + + match Cache::new(cred_dir, volume_dir, audio_dir, limit) { Ok(cache) => Some(cache), Err(e) => { warn!("Cannot create cache: {}", e); @@ -553,153 +993,461 @@ fn get_setup(args: &[String]) -> Setup { } }; - let initial_volume = matches - .opt_str(INITIAL_VOLUME) - .map(|initial_volume| { - let volume = initial_volume.parse::().unwrap(); - if volume > 100 { - error!("Initial volume must be in the range 0-100."); - // the cast will saturate, not necessary to take further action - } - (volume as f32 / 100.0 * VolumeCtrl::MAX_VOLUME as f32) as u16 - }) - .or_else(|| match mixer_name.as_deref() { - #[cfg(feature = "alsa-backend")] - Some(AlsaMixer::NAME) => None, - _ => cache.as_ref().and_then(Cache::volume), - }); - - let zeroconf_port = matches - .opt_str(ZEROCONF_PORT) - .map(|port| port.parse::().unwrap()) - .unwrap_or(0); - - let name = matches - .opt_str(NAME) - .unwrap_or_else(|| "Librespot".to_string()); - let credentials = { - let cached_credentials = cache.as_ref().and_then(Cache::credentials); + let cached_creds = cache.as_ref().and_then(Cache::credentials); - let password = |username: &String| -> Option { - write!(stderr(), "Password for {}: ", username).ok()?; - stderr().flush().ok()?; - rpassword::read_password().ok() - }; - - get_credentials( - matches.opt_str(USERNAME), - matches.opt_str(PASSWORD), - cached_credentials, - password, - ) - }; - - let session_config = { - let device_id = device_id(&name); - - SessionConfig { - user_agent: version::VERSION_STRING.to_string(), - device_id, - proxy: matches.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() { - panic!("Invalid proxy url, only URLs on the format \"http://host:port\" are allowed"); + if let Some(username) = opt_str(USERNAME) { + if username.is_empty() { + empty_string_error_msg(USERNAME, USERNAME_SHORT); + } + if let Some(password) = opt_str(PASSWORD) { + if password.is_empty() { + empty_string_error_msg(PASSWORD, PASSWORD_SHORT); + } + Some(Credentials::with_password(username, password)) + } else { + match cached_creds { + Some(creds) if username == creds.username => Some(creds), + _ => { + let prompt = &format!("Password for {}: ", username); + match rpassword::prompt_password_stderr(prompt) { + Ok(password) => { + if !password.is_empty() { + Some(Credentials::with_password(username, password)) + } else { + trace!("Password was empty."); + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds + } } - - if url.scheme() != "http" { - panic!("Only unsecure http:// proxies are supported"); + Err(e) => { + warn!("Cannot parse password: {}", e); + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds } - url - }, - Err(err) => panic!("Invalid proxy URL: {}, only URLs in the format \"http://host:port\" are allowed", err) + } } - }, - ), - ap_port: matches - .opt_str(AP_PORT) - .map(|port| port.parse::().expect("Invalid port")), + } + } + } else { + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds } }; - let player_config = { - let bitrate = matches - .opt_str(BITRATE) - .as_deref() - .map(|bitrate| Bitrate::from_str(bitrate).expect("Invalid bitrate")) - .unwrap_or_default(); + let enable_discovery = !opt_present(DISABLE_DISCOVERY); - let gapless = !matches.opt_present(DISABLE_GAPLESS); + if credentials.is_none() && !enable_discovery { + error!("Credentials are required if discovery is disabled."); + exit(1); + } - let normalisation = matches.opt_present(ENABLE_VOLUME_NORMALISATION); - let normalisation_method = matches - .opt_str(NORMALISATION_METHOD) - .as_deref() - .map(|method| { - NormalisationMethod::from_str(method).expect("Invalid normalisation method") - }) - .unwrap_or_default(); - let normalisation_type = matches - .opt_str(NORMALISATION_GAIN_TYPE) - .as_deref() - .map(|gain_type| { - NormalisationType::from_str(gain_type).expect("Invalid normalisation type") - }) - .unwrap_or_default(); - let normalisation_pregain = matches - .opt_str(NORMALISATION_PREGAIN) - .map(|pregain| pregain.parse::().expect("Invalid pregain float value")) - .unwrap_or(PlayerConfig::default().normalisation_pregain); - let normalisation_threshold = matches - .opt_str(NORMALISATION_THRESHOLD) - .map(|threshold| { - db_to_ratio( - threshold - .parse::() - .expect("Invalid threshold float value"), - ) - }) - .unwrap_or(PlayerConfig::default().normalisation_threshold); - let normalisation_attack = matches - .opt_str(NORMALISATION_ATTACK) - .map(|attack| { - Duration::from_millis(attack.parse::().expect("Invalid attack value")) - }) - .unwrap_or(PlayerConfig::default().normalisation_attack); - let normalisation_release = matches - .opt_str(NORMALISATION_RELEASE) - .map(|release| { - Duration::from_millis(release.parse::().expect("Invalid release value")) - }) - .unwrap_or(PlayerConfig::default().normalisation_release); - let normalisation_knee = matches - .opt_str(NORMALISATION_KNEE) - .map(|knee| knee.parse::().expect("Invalid knee float value")) - .unwrap_or(PlayerConfig::default().normalisation_knee); + if !enable_discovery && opt_present(ZEROCONF_PORT) { + warn!( + "With the `--{}` / `-{}` flag set `--{}` / `-{}` has no effect.", + DISABLE_DISCOVERY, DISABLE_DISCOVERY_SHORT, ZEROCONF_PORT, ZEROCONF_PORT_SHORT + ); + } - let ditherer_name = matches.opt_str(DITHER); - let ditherer = match ditherer_name.as_deref() { - // explicitly disabled on command line - Some("none") => None, - // explicitly set on command line - Some(_) => { - if format == AudioFormat::F64 || format == AudioFormat::F32 { - unimplemented!("Dithering is not available on format {:?}", format); + let zeroconf_port = if enable_discovery { + opt_str(ZEROCONF_PORT) + .map(|port| match port.parse::() { + Ok(value) if value != 0 => value, + _ => { + let valid_values = &format!("1 - {}", u16::MAX); + invalid_error_msg(ZEROCONF_PORT, ZEROCONF_PORT_SHORT, &port, valid_values, ""); + + exit(1); } - Some(dither::find_ditherer(ditherer_name).expect("Invalid ditherer")) + }) + .unwrap_or(0) + } else { + 0 + }; + + let connect_config = { + let connect_default_config = ConnectConfig::default(); + + let name = opt_str(NAME).unwrap_or_else(|| connect_default_config.name.clone()); + + if name.is_empty() { + empty_string_error_msg(NAME, NAME_SHORT); + exit(1); + } + + let initial_volume = opt_str(INITIAL_VOLUME) + .map(|initial_volume| { + let volume = match initial_volume.parse::() { + Ok(value) if (VALID_INITIAL_VOLUME_RANGE).contains(&value) => value, + _ => { + let valid_values = &format!( + "{} - {}", + VALID_INITIAL_VOLUME_RANGE.start(), + VALID_INITIAL_VOLUME_RANGE.end() + ); + + #[cfg(feature = "alsa-backend")] + let default_value = &format!( + "{}, or the current value when the alsa mixer is used.", + connect_default_config.initial_volume.unwrap_or_default() + ); + + #[cfg(not(feature = "alsa-backend"))] + let default_value = &connect_default_config + .initial_volume + .unwrap_or_default() + .to_string(); + + invalid_error_msg( + INITIAL_VOLUME, + INITIAL_VOLUME_SHORT, + &initial_volume, + valid_values, + default_value, + ); + + exit(1); + } + }; + + (volume as f32 / 100.0 * VolumeCtrl::MAX_VOLUME as f32) as u16 + }) + .or_else(|| match mixer_type.as_deref() { + #[cfg(feature = "alsa-backend")] + Some(AlsaMixer::NAME) => None, + _ => cache.as_ref().and_then(Cache::volume), + }); + + let device_type = opt_str(DEVICE_TYPE) + .as_deref() + .map(|device_type| { + DeviceType::from_str(device_type).unwrap_or_else(|_| { + invalid_error_msg( + DEVICE_TYPE, + DEVICE_TYPE_SHORT, + device_type, + "computer, tablet, smartphone, \ + speaker, tv, avr, stb, audiodongle, \ + gameconsole, castaudio, castvideo, \ + automobile, smartwatch, chromebook, \ + carthing, homething", + DeviceType::default().into(), + ); + + exit(1); + }) + }) + .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 { + 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(s)://host:port\" are allowed"); + exit(1); + } + + url + }, + Err(e) => { + error!("Invalid proxy URL: \"{}\", only URLs in the format \"http(s)://host:port\" are allowed", e); + exit(1); + } + } + }, + ), + ap_port: opt_str(AP_PORT).map(|port| match port.parse::() { + Ok(value) if value != 0 => value, + _ => { + let valid_values = &format!("1 - {}", u16::MAX); + invalid_error_msg(AP_PORT, AP_PORT_SHORT, &port, valid_values, ""); + + exit(1); } - // nothing set on command line => use default + }), + tmp_dir, + }; + + let player_config = { + let player_default_config = PlayerConfig::default(); + + let bitrate = opt_str(BITRATE) + .as_deref() + .map(|bitrate| { + Bitrate::from_str(bitrate).unwrap_or_else(|_| { + invalid_error_msg(BITRATE, BITRATE_SHORT, bitrate, "96, 160, 320", "160"); + exit(1); + }) + }) + .unwrap_or(player_default_config.bitrate); + + let gapless = !opt_present(DISABLE_GAPLESS); + + let normalisation = opt_present(ENABLE_VOLUME_NORMALISATION); + + let normalisation_method; + let normalisation_type; + let normalisation_pregain; + let normalisation_threshold; + let normalisation_attack; + let normalisation_release; + let normalisation_knee; + + if !normalisation { + for a in &[ + NORMALISATION_METHOD, + NORMALISATION_GAIN_TYPE, + NORMALISATION_PREGAIN, + NORMALISATION_THRESHOLD, + NORMALISATION_ATTACK, + NORMALISATION_RELEASE, + NORMALISATION_KNEE, + ] { + if opt_present(a) { + warn!( + "Without the `--{}` / `-{}` flag normalisation options have no effect.", + ENABLE_VOLUME_NORMALISATION, ENABLE_VOLUME_NORMALISATION_SHORT, + ); + break; + } + } + + normalisation_method = player_default_config.normalisation_method; + normalisation_type = player_default_config.normalisation_type; + normalisation_pregain = player_default_config.normalisation_pregain; + normalisation_threshold = player_default_config.normalisation_threshold; + normalisation_attack = player_default_config.normalisation_attack; + normalisation_release = player_default_config.normalisation_release; + normalisation_knee = player_default_config.normalisation_knee; + } else { + normalisation_method = opt_str(NORMALISATION_METHOD) + .as_deref() + .map(|method| { + warn!( + "`--{}` / `-{}` will be deprecated in a future release.", + NORMALISATION_METHOD, NORMALISATION_METHOD_SHORT + ); + + let method = NormalisationMethod::from_str(method).unwrap_or_else(|_| { + invalid_error_msg( + NORMALISATION_METHOD, + NORMALISATION_METHOD_SHORT, + method, + "basic, dynamic", + &format!("{:?}", player_default_config.normalisation_method), + ); + + exit(1); + }); + + if matches!(method, NormalisationMethod::Basic) { + warn!( + "`--{}` / `-{}` {:?} will be deprecated in a future release.", + NORMALISATION_METHOD, NORMALISATION_METHOD_SHORT, method + ); + } + + method + }) + .unwrap_or(player_default_config.normalisation_method); + + normalisation_type = opt_str(NORMALISATION_GAIN_TYPE) + .as_deref() + .map(|gain_type| { + NormalisationType::from_str(gain_type).unwrap_or_else(|_| { + invalid_error_msg( + NORMALISATION_GAIN_TYPE, + NORMALISATION_GAIN_TYPE_SHORT, + gain_type, + "track, album, auto", + &format!("{:?}", player_default_config.normalisation_type), + ); + + exit(1); + }) + }) + .unwrap_or(player_default_config.normalisation_type); + + normalisation_pregain = opt_str(NORMALISATION_PREGAIN) + .map(|pregain| match pregain.parse::() { + Ok(value) if (VALID_NORMALISATION_PREGAIN_RANGE).contains(&value) => value, + _ => { + let valid_values = &format!( + "{} - {}", + VALID_NORMALISATION_PREGAIN_RANGE.start(), + VALID_NORMALISATION_PREGAIN_RANGE.end() + ); + + invalid_error_msg( + NORMALISATION_PREGAIN, + NORMALISATION_PREGAIN_SHORT, + &pregain, + valid_values, + &player_default_config.normalisation_pregain.to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_pregain); + + normalisation_threshold = opt_str(NORMALISATION_THRESHOLD) + .map(|threshold| match threshold.parse::() { + Ok(value) if (VALID_NORMALISATION_THRESHOLD_RANGE).contains(&value) => { + db_to_ratio(value) + } + _ => { + let valid_values = &format!( + "{} - {}", + VALID_NORMALISATION_THRESHOLD_RANGE.start(), + VALID_NORMALISATION_THRESHOLD_RANGE.end() + ); + + invalid_error_msg( + NORMALISATION_THRESHOLD, + NORMALISATION_THRESHOLD_SHORT, + &threshold, + valid_values, + &ratio_to_db(player_default_config.normalisation_threshold).to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_threshold); + + normalisation_attack = opt_str(NORMALISATION_ATTACK) + .map(|attack| match attack.parse::() { + Ok(value) if (VALID_NORMALISATION_ATTACK_RANGE).contains(&value) => { + Duration::from_millis(value) + } + _ => { + let valid_values = &format!( + "{} - {}", + VALID_NORMALISATION_ATTACK_RANGE.start(), + VALID_NORMALISATION_ATTACK_RANGE.end() + ); + + invalid_error_msg( + NORMALISATION_ATTACK, + NORMALISATION_ATTACK_SHORT, + &attack, + valid_values, + &player_default_config + .normalisation_attack + .as_millis() + .to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_attack); + + normalisation_release = opt_str(NORMALISATION_RELEASE) + .map(|release| match release.parse::() { + Ok(value) if (VALID_NORMALISATION_RELEASE_RANGE).contains(&value) => { + Duration::from_millis(value) + } + _ => { + let valid_values = &format!( + "{} - {}", + VALID_NORMALISATION_RELEASE_RANGE.start(), + VALID_NORMALISATION_RELEASE_RANGE.end() + ); + + invalid_error_msg( + NORMALISATION_RELEASE, + NORMALISATION_RELEASE_SHORT, + &release, + valid_values, + &player_default_config + .normalisation_release + .as_millis() + .to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_release); + + normalisation_knee = opt_str(NORMALISATION_KNEE) + .map(|knee| match knee.parse::() { + Ok(value) if (VALID_NORMALISATION_KNEE_RANGE).contains(&value) => value, + _ => { + let valid_values = &format!( + "{} - {}", + VALID_NORMALISATION_KNEE_RANGE.start(), + VALID_NORMALISATION_KNEE_RANGE.end() + ); + + invalid_error_msg( + NORMALISATION_KNEE, + NORMALISATION_KNEE_SHORT, + &knee, + valid_values, + &player_default_config.normalisation_knee.to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_knee); + } + + let ditherer_name = opt_str(DITHER); + let ditherer = match ditherer_name.as_deref() { + Some(value) => match value { + "none" => None, + _ => match format { + AudioFormat::F64 | AudioFormat::F32 => { + error!("Dithering is not available with format: {:?}.", format); + exit(1); + } + _ => Some(dither::find_ditherer(ditherer_name).unwrap_or_else(|| { + invalid_error_msg( + DITHER, + DITHER_SHORT, + &opt_str(DITHER).unwrap_or_default(), + "none, gpdf, tpdf, tpdf_hp for formats S16, S24, S24_3, S32, none for formats F32, F64", + "tpdf for formats S16, S24, S24_3 and none for formats S32, F32, F64", + ); + + exit(1); + })), + }, + }, None => match format { AudioFormat::S16 | AudioFormat::S24 | AudioFormat::S24_3 => { - PlayerConfig::default().ditherer + player_default_config.ditherer } _ => None, }, }; - let passthrough = matches.opt_present(PASSTHROUGH); + let passthrough = opt_present(PASSTHROUGH); PlayerConfig { bitrate, @@ -717,27 +1465,8 @@ fn get_setup(args: &[String]) -> Setup { } }; - let connect_config = { - let device_type = matches - .opt_str(DEVICE_TYPE) - .as_deref() - .map(|device_type| DeviceType::from_str(device_type).expect("Invalid device type")) - .unwrap_or_default(); - let has_volume_ctrl = !matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed); - let autoplay = matches.opt_present(AUTOPLAY); - - ConnectConfig { - name, - device_type, - initial_volume, - has_volume_ctrl, - autoplay, - } - }; - - let enable_discovery = !matches.opt_present(DISABLE_DISCOVERY); - let player_event_program = matches.opt_str(ONEVENT); - let emit_sink_events = matches.opt_present(EMIT_SINK_EVENTS); + let player_event_program = opt_str(ONEVENT); + let emit_sink_events = opt_present(EMIT_SINK_EVENTS); Setup { format, @@ -764,8 +1493,7 @@ async fn main() { env::set_var(RUST_BACKTRACE, "full") } - let args: Vec = std::env::args().collect(); - let setup = get_setup(&args); + let setup = get_setup(); let mut last_credentials = None; let mut spirc: Option = None; @@ -809,7 +1537,9 @@ 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 @@ -864,14 +1594,20 @@ async fn main() { } }; - let (spirc_, spirc_task_) = Spirc::new(connect_config, session, player, mixer); - + let (spirc_, spirc_task_) = match Spirc::new(connect_config, session, player, mixer) { + 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); }, Err(e) => { - warn!("Connection failed: {}", e); + error!("Connection failed: {}", e); + exit(1); } }, _ = async { spirc_task.as_mut().unwrap().await }, if spirc_task.is_some() => { @@ -941,7 +1677,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! {