diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..9860815f --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 +ARG debian_version=slim-bookworm +ARG rust_version=1.85.0 +FROM rust:${rust_version}-${debian_version} + +ARG DEBIAN_FRONTEND=noninteractive +ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL="sparse" +ENV RUST_BACKTRACE=1 +ENV RUSTFLAGS="-D warnings" + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git \ + nano\ + openssh-server \ + # for rust-analyzer vscode plugin + pkg-config \ + # developer dependencies + libunwind-dev \ + libpulse-dev \ + portaudio19-dev \ + libasound2-dev \ + libsdl2-dev \ + gstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libavahi-compat-libdnssd-dev && \ + rm -rf /var/lib/apt/lists/* + +RUN rustup component add rustfmt && \ + rustup component add clippy && \ + cargo install cargo-hack diff --git a/.devcontainer/Dockerfile.alpine b/.devcontainer/Dockerfile.alpine new file mode 100644 index 00000000..08e0f07d --- /dev/null +++ b/.devcontainer/Dockerfile.alpine @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 +ARG alpine_version=alpine3.20 +ARG rust_version=1.85.0 +FROM rust:${rust_version}-${alpine_version} + +ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL="sparse" +ENV RUST_BACKTRACE=1 +ENV RUSTFLAGS="-D warnings -C target-feature=-crt-static" + +RUN apk add --no-cache \ + git \ + nano\ + openssh-server \ + # for rust-analyzer vscode plugin + pkgconf \ + musl-dev \ + # developer dependencies + openssl-dev \ + libunwind-dev \ + pulseaudio-dev \ + portaudio-dev \ + alsa-lib-dev \ + sdl2-dev \ + gstreamer-dev \ + gst-plugins-base-dev \ + jack-dev \ + avahi-dev && \ + rm -rf /lib/apk/db/* + +RUN rustup component add rustfmt && \ + rustup component add clippy && \ + cargo install cargo-hack diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..73fa03f4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,20 @@ +{ + "name": "Librespot Devcontainer", + "dockerFile": "Dockerfile.alpine", + "_postCreateCommand_comment": "Uncomment 'postCreateCommand' to run commands after the container is created.", + "_postCreateCommand": "", + "customizations": { + "_comment": "Configure properties specific to VS Code.", + "vscode": { + "settings": { + "dev.containers.copyGitConfig": true + }, + "extensions": ["eamodio.gitlens", "github.vscode-github-actions", "rust-lang.rust-analyzer"] + } + }, + "containerEnv": { + "GIT_EDITOR": "nano" + }, + "_remoteUser_comment": "Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root", + "_remoteUser": "root" +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..cd235728 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +### Look for similar bugs +Please check if there's [already an issue](https://github.com/librespot-org/librespot/issues) for your problem. +If you've only a "me too" comment to make, consider if a :+1: [reaction](https://github.blog/news-insights/product-news/add-reactions-to-pull-requests-issues-and-comments/) +will suffice. + +### Description +A clear and concise description of what the problem is. + +### Version +What version(s) of *librespot* does this problem exist in? + +### How to reproduce +Steps to reproduce the behavior in *librespot* e.g. +1. Launch `librespot` with '...' +2. Connect with '...' +3. In the client click on '...' +4. See some error/problem + +### Log +* A *full* **debug** log so we may trace your problem (launch `librespot` with `--verbose`). +* Ideally contains your above steps to reproduce. +* Format the log as code ([help](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks)) or use a *non-expiring* [pastebin](https://pastebin.com/). +* Redact data you consider personal but do not remove/trim anything else. + +### Host (what you are running `librespot` on): +- OS: [e.g. Linux] +- Platform: [e.g. RPi 3B+] + +### Additional context +Add any other context about the problem here. If your issue is related to sound playback, at a minimum specify the type and make of your output device. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..11fc491e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..89d9269a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: saturday + time: "10:00" + open-pull-requests-limit: 10 + target-branch: dev diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..88b1b8c5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,101 @@ +--- +# Note, this is used in the badge URL! +name: build + +"on": + push: + branches: [dev, master] + paths-ignore: + - "**.md" + - "docs/**" + - "contrib/**" + - "LICENSE" + - "*.sh" + - "**/Dockerfile*" + - "publish.sh" + - "test.sh" + pull_request: + paths-ignore: + - "**.md" + - "docs/**" + - "contrib/**" + - "LICENSE" + - "*.sh" + - "**/Dockerfile*" + - "publish.sh" + - "test.sh" + schedule: + # Run CI every week + - cron: "00 01 * * 0" + +env: + RUST_BACKTRACE: 1 + RUSTFLAGS: -D warnings + +jobs: + test: + name: cargo +${{ matrix.toolchain }} test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + toolchain: + - "1.85" # MSRV (Minimum supported rust version) + - stable + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Install developer package dependencies (Linux) + if: runner.os == 'Linux' + run: > + sudo apt-get update && sudo apt-get install -y + libpulse-dev portaudio19-dev libasound2-dev libsdl2-dev + gstreamer1.0-dev libgstreamer-plugins-base1.0-dev + libavahi-compat-libdnssd-dev + + - name: Fetch dependencies + run: cargo fetch --locked + + - name: Build workspace with examples + run: cargo build --frozen --workspace --examples + + - name: Run tests + run: cargo test --workspace + + - name: Install cargo-hack + uses: taiki-e/install-action@cargo-hack + + - name: Check packages without TLS requirements + run: cargo hack check -p librespot-protocol --each-feature + + - name: Check workspace with native-tls + run: > + cargo hack check -p librespot --each-feature --exclude-all-features + --include-features native-tls + --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots + + - name: Check workspace with rustls-tls-native-roots + run: > + cargo hack check -p librespot --each-feature --exclude-all-features + --include-features rustls-tls-native-roots + --exclude-features native-tls,rustls-tls-webpki-roots + + - name: Build binary with default features + run: cargo build --frozen + + - name: Upload debug artifacts + uses: actions/upload-artifact@v4 + with: + name: librespot-${{ matrix.os }}-${{ matrix.toolchain }} + path: > + target/debug/librespot${{ runner.os == 'Windows' && '.exe' || '' }} + if-no-files-found: error diff --git a/.github/workflows/cross-compile.yml b/.github/workflows/cross-compile.yml new file mode 100644 index 00000000..79f7f586 --- /dev/null +++ b/.github/workflows/cross-compile.yml @@ -0,0 +1,78 @@ +--- +name: cross-compile + +"on": + push: + branches: [dev, master] + paths-ignore: + - "**.md" + - "docs/**" + - "contrib/**" + - "LICENSE" + - "*.sh" + - "**/Dockerfile*" + pull_request: + paths-ignore: + - "**.md" + - "docs/**" + - "contrib/**" + - "LICENSE" + - "*.sh" + - "**/Dockerfile*" + +env: + RUST_BACKTRACE: 1 + RUSTFLAGS: -D warnings + +jobs: + cross-compile: + name: cross +${{ matrix.toolchain }} build ${{ matrix.platform.target }} + runs-on: ${{ matrix.platform.runs-on }} + continue-on-error: false + strategy: + matrix: + platform: + - arch: armv7 + runs-on: ubuntu-latest + target: armv7-unknown-linux-gnueabihf + + - arch: aarch64 + runs-on: ubuntu-latest + target: aarch64-unknown-linux-gnu + + - arch: riscv64gc + runs-on: ubuntu-latest + target: riscv64gc-unknown-linux-gnu + + toolchain: + - "1.85" # MSRV (Minimum Supported Rust Version) + - stable + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Build binary with default features + if: matrix.platform.target != 'riscv64gc-unknown-linux-gnu' + uses: houseabsolute/actions-rust-cross@v1 + with: + command: build + target: ${{ matrix.platform.target }} + toolchain: ${{ matrix.toolchain }} + args: --locked --verbose + + - name: Build binary without system dependencies + if: matrix.platform.target == 'riscv64gc-unknown-linux-gnu' + uses: houseabsolute/actions-rust-cross@v1 + with: + command: build + target: ${{ matrix.platform.target }} + toolchain: ${{ matrix.toolchain }} + args: --locked --verbose --no-default-features --features rustls-tls-webpki-roots + + - name: Upload debug artifacts + uses: actions/upload-artifact@v4 + with: + name: librespot-${{ matrix.platform.runs-on }}-${{ matrix.platform.arch }}-${{ matrix.toolchain }} # yamllint disable-line rule:line-length + path: target/${{ matrix.platform.target }}/debug/librespot + if-no-files-found: error diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 00000000..fffa8a56 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,87 @@ +--- +name: code-quality + +"on": + push: + branches: [dev, master] + paths-ignore: + - "**.md" + - "docs/**" + - "contrib/**" + - "LICENSE" + - "*.sh" + - "**/Dockerfile*" + pull_request: + paths-ignore: + - "**.md" + - "docs/**" + - "contrib/**" + - "LICENSE" + - "*.sh" + - "**/Dockerfile*" + schedule: + # Run CI every week + - cron: "00 01 * * 0" + +env: + RUST_BACKTRACE: 1 + RUSTFLAGS: -D warnings + +jobs: + fmt: + name: cargo fmt + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + needs: fmt + name: cargo clippy + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Install developer package dependencies + run: > + sudo apt-get update && sudo apt-get install -y + libpulse-dev portaudio19-dev libasound2-dev libsdl2-dev + gstreamer1.0-dev libgstreamer-plugins-base1.0-dev + libavahi-compat-libdnssd-dev + + - name: Install cargo-hack + uses: taiki-e/install-action@cargo-hack + + - name: Run clippy on packages without TLS requirements + run: cargo hack clippy -p librespot-protocol --each-feature + + - name: Run clippy with native-tls + run: > + cargo hack clippy -p librespot --each-feature --exclude-all-features + --include-features native-tls + --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots + + - name: Run clippy with rustls-tls-native-roots + run: > + cargo hack clippy -p librespot --each-feature --exclude-all-features + --include-features rustls-tls-native-roots + --exclude-features native-tls,rustls-tls-webpki-roots + + - name: Run clippy with rustls-tls-webpki-roots + run: > + cargo hack clippy -p librespot --each-feature --exclude-all-features + --include-features rustls-tls-webpki-roots + --exclude-features native-tls,rustls-tls-native-roots diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 6e447ff9..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,199 +0,0 @@ -# Note, this is used in the badge URL! -name: test - -on: - push: - branches: [master, dev] - paths: - [ - "**.rs", - "Cargo.toml", - "Cargo.lock", - "rustfmt.toml", - ".github/workflows/*", - "!*.md", - "!contrib/*", - "!docs/*", - "!LICENSE", - "!*.sh", - ] - pull_request: - paths: - [ - "**.rs", - "Cargo.toml", - "Cargo.lock", - "rustfmt.toml", - ".github/workflows/*", - "!*.md", - "!contrib/*", - "!docs/*", - "!LICENSE", - "!*.sh", - ] - schedule: - # Run CI every week - - cron: "00 01 * * 0" - -env: - RUST_BACKTRACE: 1 - -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 }}) - runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.experimental }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - toolchain: - - 1.48 # MSRV (Minimum supported rust version) - - stable - - beta - experimental: [false] - # Ignore failures in nightly - include: - - os: ubuntu-latest - toolchain: nightly - experimental: true - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: ${{ matrix.toolchain }} - override: true - - - 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 build --workspace --examples - - 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 - - test-windows: - needs: fmt - name: cargo build (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [windows-latest] - toolchain: [stable] - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ matrix.toolchain }} - profile: minimal - override: true - - - 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') }} - - - run: cargo build --workspace --examples - - run: cargo test --workspace - - - run: cargo install cargo-hack - - run: cargo hack --workspace --remove-dev-deps - - run: cargo build --no-default-features - - run: cargo build - - test-cross-arm: - needs: fmt - runs-on: ${{ matrix.os }} - continue-on-error: false - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - target: armv7-unknown-linux-gnueabihf - toolchain: stable - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - profile: minimal - target: ${{ matrix.target }} - toolchain: ${{ matrix.toolchain }} - override: true - - - 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 }}-${{ matrix.target }}-${{ steps.get-rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock') }} - - name: Install cross - run: cargo install cross || true - - name: Build - run: cross build --locked --target ${{ matrix.target }} --no-default-features diff --git a/.gitignore b/.gitignore index 1fa44327..aa13aaf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ target .cargo spotify_appkey.key +.idea/ .vagrant/ .project .history +.cache *.save - - +*.*~ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e362ae6..a11d932e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,23 +2,380 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0. ## [Unreleased] +### Added + +- [core] Add `SpotifyUri` type to represent more types of URI than `SpotifyId` can + +### Changed + +- [playback] Changed type of `SpotifyId` fields in `PlayerEvent` members to `SpotifyUri` (breaking) +- [metadata] Changed arguments for `Metadata` trait from `&SpotifyId` to `&SpotifyUri` (breaking) +- [player] `load` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking) +- [player] `preload` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking) +- [spclient] `get_radio_for_track` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking) + + +### Removed + +- [core] Removed `SpotifyItemType` enum; the new `SpotifyUri` is an enum over all item types and so which variant it is + describes its item type (breaking) +- [core] Removed `NamedSpotifyId` struct; it was made obsolete by `SpotifyUri` (breaking) +- [core] The following methods have been removed from `SpotifyId` and moved to `SpotifyUri` (breaking): + - `is_playable` + - `from_uri` + - `to_uri` + +## [v0.7.1] - 2025-08-31 + +### Changed + +- [connect] Shuffling was adjusted, so that shuffle and repeat can be used combined + +### Fixed + +- [connect] Repeat context will not go into autoplay anymore and triggering autoplay while shuffling shouldn't reshuffle anymore +- [connect] Only deletes the connect state on dealer shutdown instead on disconnecting +- [core] Fixed a problem where in `spclient` where an HTTP/411 error was thrown because the header was set wrong +- [main] Use the config instead of the type default for values that are not provided by the user + +## [0.7.0] - 2025-08-24 + +### Changed + +- [core] MSRV is now 1.85 with Rust edition 2024 (breaking) +- [core] AP connect and handshake have a combined 5 second timeout. +- [core] `stream_from_cdn` now accepts the URL as `TryInto` instead of `CdnUrl` (breaking) +- [core] Add TLS backend selection with native-tls and rustls-tls options, defaulting to native-tls +- [connect] Replaced `has_volume_ctrl` with `disable_volume` in `ConnectConfig` (breaking) +- [connect] Changed `initial_volume` from `Option` to `u16` in `ConnectConfig` (breaking) +- [connect] Replaced `SpircLoadCommand` with `LoadRequest`, `LoadRequestOptions` and `LoadContextOptions` (breaking) +- [connect] Moved all public items to the highest level (breaking) +- [connect] Replaced Mercury usage in `Spirc` with Dealer +- [metadata] Replaced `AudioFileFormat` with own enum. (breaking) +- [playback] Changed trait `Mixer::open` to return `Result` instead of `Self` (breaking) +- [playback] Changed type alias `MixerFn` to return `Result, Error>` instead of `Arc` (breaking) +- [playback] Optimize audio conversion to always dither at 16-bit level, and improve performance +- [playback] Normalizer maintains better stereo imaging, while also being faster +- [oauth] Remove loopback address requirement from `redirect_uri` when spawning callback handling server versus using stdin. + +### Added + +- [connect] Add command line parameter for setting volume steps. +- [connect] Add support for `seek_to`, `repeat_track` and `autoplay` for `Spirc` loading +- [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking) +- [connect] Add `volume_steps` to `ConnectConfig` (breaking) +- [connect] Add and enforce rustdoc +- [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking) +- [playback] Add `PlayerEvent::PositionChanged` event to notify about the current playback position +- [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient` +- [core] Add `try_get_urls` to `CdnUrl` +- [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process + +### Fixed + +- [test] Missing bindgen breaks crossbuild on recent runners. Now installing latest bindgen in addition. +- [core] Fix "no native root CA certificates found" on platforms unsupported + by `rustls-native-certs`. +- [core] Fix all APs rejecting with "TryAnotherAP" when connecting session + on Android platform. +- [core] Fix "Invalid Credentials" when using a Keymaster access token and + client ID on Android platform. +- [connect] Fix "play" command not handled if missing "offset" property +- [discovery] Fix libmdns zerconf setup errors not propagating to the main task. +- [metadata] `Show::trailer_uri` is now optional since it isn't always present (breaking) +- [metadata] Fix incorrect parsing of audio format +- [connect] Handle transfer of playback with empty "uri" field +- [connect] Correctly apply playing/paused state when transferring playback +- [player] Saturate invalid seek positions to track duration +- [audio] Fall back to other URLs in case of a failure when downloading from CDN +- [core] Metadata requests failing with 500 Internal Server Error +- [player] Rodio backend did not honor audio output format request + +### Deprecated + +- [oauth] `get_access_token()` function marked for deprecation +- [core] `try_get_url()` function marked for deprecation + +### Removed + +- [core] Removed `get_canvases` from SpClient (breaking) +- [core] DeviceType `homething` removed due to crashes on Android (breaking) +- [metadata] Removed `genres` from Album (breaking) +- [metadata] Removed `genre` from Artists (breaking) + +## [0.6.0] - 2024-10-30 + +This version takes another step into the direction of the HTTP API, fixes a +couple of bugs, and makes it easier for developers to mock a certain platform. +Also it adds the option to choose avahi, dnssd or libmdns as your zeroconf +backend for Spotify Connect discovery. + +### Changed + +- [core] The `access_token` for http requests is now acquired by `login5` +- [core] MSRV is now 1.75 (breaking) +- [discovery] librespot can now be compiled with multiple MDNS/DNS-SD backends + (avahi, dns_sd, libmdns) which can be selected using a CLI flag. The defaults + are unchanged (breaking). + +### Added + +- [core] Add `get_token_with_client_id()` to get a token for a specific client ID +- [core] Add `login` (mobile) and `auth_token` retrieval via login5 +- [core] Add `OS` and `os_version` to `config.rs` +- [discovery] Added a new MDNS/DNS-SD backend which connects to Avahi via D-Bus. + +### Fixed + +- [connect] Fixes initial volume showing zero despite playing in full volume instead +- [core] Fix "source slice length (16) does not match destination slice length + (20)" panic on some tracks + +## [0.5.0] - 2024-10-15 + +This version is be a major departure from the architecture up until now. It +focuses on implementing the "new Spotify API". This means moving large parts +of the Spotify protocol from Mercury to HTTP. A lot of this was reverse +engineered before by @devgianlu of librespot-java. It was long overdue that we +started implementing it too, not in the least because new features like the +hopefully upcoming Spotify HiFi depend on it. + +Splitting up the work on the new Spotify API, v0.5.0 brings HTTP-based file +downloads and metadata access. Implementing the "dealer" (replacing the current +Mercury-based SPIRC message bus with WebSockets, also required for social plays) +is a large and separate effort, slated for some later release. + +While at it, we are taking the liberty to do some major refactoring to make +librespot more robust. Consequently not only the Spotify API changed but large +parts of the librespot API too. For downstream maintainers, we realise that it +can be a lot to move from the current codebase to this one, but believe us it +will be well worth it. + +All these changes are likely to introduce new bugs as well as some regressions. +We appreciate all your testing and contributions to the repository: +https://github.com/librespot-org/librespot + +### Changed + +- [all] Assertions were changed into `Result` or removed (breaking) +- [all] Purge use of `unwrap`, `expect` and return `Result` (breaking) +- [all] `chrono` replaced with `time` (breaking) +- [all] `time` updated (CVE-2020-26235) +- [all] Improve lock contention and performance (breaking) +- [all] Use a single `player` instance. Eliminates occasional `player` and + `audio backend` restarts, which can cause issues with some playback + configurations. +- [all] Updated and removed unused dependencies +- [audio] Files are now downloaded over the HTTPS CDN (breaking) +- [audio] Improve file opening and seeking performance (breaking) +- [core] MSRV is now 1.74 (breaking) +- [connect] `DeviceType` moved out of `connect` into `core` (breaking) +- [connect] Update and expose all `spirc` context fields (breaking) +- [connect] Add `Clone, Defaut` traits to `spirc` contexts +- [connect] Autoplay contexts are now retrieved with the `spclient` (breaking) +- [contrib] Updated Docker image +- [core] Message listeners are registered before authenticating. As a result + there now is a separate `Session::new` and subsequent `session.connect`. + (breaking) +- [core] `ConnectConfig` moved out of `core` into `connect` (breaking) +- [core] `client_id` for `get_token` moved to `SessionConfig` (breaking) +- [core] Mercury code has been refactored for better legibility (breaking) +- [core] Cache resolved access points during runtime (breaking) +- [core] `FileId` is moved out of `SpotifyId`. For now it will be re-exported. +- [core] Report actual platform data on login +- [core] Support `Session` authentication with a Spotify access token +- [core] `Credentials.username` is now an `Option` (breaking) +- [core] `Session::connect` tries multiple access points, retrying each one. +- [core] Each access point connection now timesout after 3 seconds. +- [core] Listen on both IPV4 and IPV6 on non-windows hosts +- [main] `autoplay {on|off}` now acts as an override. If unspecified, `librespot` + now follows the setting in the Connect client that controls it. (breaking) +- [metadata] Most metadata is now retrieved with the `spclient` (breaking) +- [metadata] Playlists are moved to the `playlist4_external` protobuf (breaking) +- [metadata] Handle playlists that are sent with microsecond-based timestamps +- [playback] The audio decoder has been switched from `lewton` to `Symphonia`. + This improves the Vorbis sound quality, adds support for MP3 as well as for + FLAC in the future. (breaking) +- [playback] Improve reporting of actual playback cursor +- [playback] The passthrough decoder is now feature-gated (breaking) +- [playback] `rodio`: call play and pause +- [protocol] protobufs have been updated + +### Added + +- [all] Check that array indexes are within bounds (panic safety) +- [all] Wrap errors in librespot `Error` type (breaking) +- [audio] Make audio fetch parameters tunable +- [connect] Add option on which zeroconf will bind. Defaults to all interfaces. Ignored by DNS-SD. +- [connect] Add session events +- [connect] Add `repeat`, `set_position_ms` and `set_volume` to `spirc.rs` +- [contrib] Add `event_handler_example.py` +- [core] Send metrics with metadata queries: client ID, country & product +- [core] Verify Spotify server certificates (prevents man-in-the-middle attacks) +- [core] User attributes are stored in `Session` upon login, accessible with a + getter and setter, and automatically updated as changes are pushed by the + Spotify infrastructure (breaking) +- [core] HTTPS is now supported, including for proxies (breaking) +- [core] Resolve `spclient` and `dealer` access points (breaking) +- [core] Get and cache tokens through new token provider (breaking) +- [core] `spclient` is the API for HTTP-based calls to the Spotify servers. + It supports a lot of functionality, including audio previews and image + downloads even if librespot doesn't use that for playback itself. +- [core] Support downloading of lyrics +- [core] Support parsing `SpotifyId` for local files +- [core] Support parsing `SpotifyId` for named playlists +- [core] Add checks and handling for stale server connections. +- [core] Fix potential deadlock waiting for audio decryption keys. +- [discovery] Add option to show playback device as a group +- [main] Add all player events to `player_event_handler.rs` +- [main] Add an event worker thread that runs async to the main thread(s) but + sync to itself to prevent potential data races for event consumers +- [metadata] All metadata fields in the protobufs are now exposed (breaking) +- [oauth] Standalone module to obtain Spotify access token using OAuth authorization code flow. +- [playback] Explicit tracks are skipped if the controlling Connect client has + disabled such content. Applications that use librespot as a library without + Connect should use the 'filter-explicit-content' user attribute in the session. +- [playback] Add metadata support via a `TrackChanged` event +- [connect] Add `activate` and `load` functions to `Spirc`, allowing control over local connect sessions +- [metadata] Add `Lyrics` +- [discovery] Add discovery initialisation retries if within the 1st min of uptime + +### Fixed + +- [connect] Set `PlayStatus` to the correct value when Player is loading to + avoid blanking out the controls when `self.play_status` is `LoadingPlay` or + `LoadingPause` in `spirc.rs` +- [connect] Handle attempts to play local files better by basically ignoring + attempts to load them in `handle_remote_update` in `spirc.rs` +- [connect] Loading previous or next tracks, or looping back on repeat, will + only start playback when we were already playing +- [connect, playback] Clean up and de-noise events and event firing +- [core] Fixed frequent disconnections for some users +- [core] More strict Spotify ID parsing +- [discovery] Update active user field upon connection +- [playback] Handle invalid track start positions by just starting the track + from the beginning +- [playback] Handle disappearing and invalid devices better +- [playback] Handle seek, pause, and play commands while loading +- [playback] Handle disabled normalisation correctly when using fixed volume +- [playback] Do not stop sink in gapless mode +- [metadata] Fix missing colon when converting named spotify IDs to URIs + +## [0.4.2] - 2022-07-29 + +Besides a couple of small fixes, this point release is mainly to blacklist the +ap-gew4 and ap-gue1 access points that caused librespot to fail to playback +anything. + +Development will now shift to the new HTTP-based API, targeted for a future +v0.5.0 release. The new-api branch will therefore be promoted to dev. This is a +major departure from the old API and although it brings many exciting new +things, it is also likely to introduce new bugs and some regressions. + +Long story short, this v0.4.2 release is the most stable that librespot has yet +to offer. But, unless anything big comes up, it is also intended as the last +release to be based on the old API. Happy listening. + +### Changed + +- [playback] `pipe`: Better error handling +- [playback] `subprocess`: Better error handling + +### Added + +- [core] `apresolve`: Blacklist ap-gew4 and ap-gue1 access points that cause channel errors +- [playback] `pipe`: Implement stop + +### Fixed + +- [main] fix `--opt=value` line argument logging +- [playback] `alsamixer`: make `--volume-ctrl fixed` work as expected when combined with `--mixer alsa` + +## [0.4.1] - 2022-05-23 + +This release fixes dependency issues when installing from crates. + +### Changed + +- [chore] The MSRV is now 1.56 + +### Fixed + +- [playback] Fixed dependency issues when installing from crate + +## [0.4.0] - 2022-05-21 + +Note: This version was yanked, because a corrupt package was uploaded and failed +to install. + +This is a polishing release, adding a few little extras and improving on many +thers. We had to break a couple of API's to do so, and therefore bumped the +minor version number. v0.4.x may be the last in series before we migrate from +the current channel-based Spotify backend to a more HTTP-based backend. +Targeting that major effort for a v0.5 release sometime, we intend to maintain +v0.4.x as a stable branch until then. + +### Changed + +- [chore] The MSRV is now 1.53 +- [contrib] Hardened security of the `systemd` service units +- [core] `Session`: `connect()` now returns the long-term credentials +- [core] `Session`: `connect()` now accepts a flag if the credentails should be stored via the cache +- [main] Different option descriptions and error messages based on what backends are enabled at build time +- [playback] More robust dynamic limiter for very wide dynamic range (breaking) +- [playback] `alsa`: improve `--device ?` output for the Alsa backend +- [playback] `gstreamer`: create own context, set correct states and use sync handler +- [playback] `pipe`: create file if it doesn't already exist +- [playback] `Sink`: `write()` now receives ownership of the packet (breaking) + +### Added + +- [main] Enforce reasonable ranges for option values (breaking) +- [main] Add the ability to parse environment variables +- [main] Log now emits warning when trying to use options that would otherwise have no effect +- [main] Verbose logging now logs all parsed environment variables and command line arguments (credentials are redacted) +- [main] Add a `-q`, `--quiet` option that changes the logging level to WARN +- [main] Add `disable-credential-cache` flag (breaking) +- [main] Add a short name for every flag and option +- [playback] `pulseaudio`: set the PulseAudio name to match librespot's device name via `PULSE_PROP_application.name` environment variable (user set env var value takes precedence) (breaking) +- [playback] `pulseaudio`: set icon to `audio-x-generic` so we get an icon instead of a placeholder via `PULSE_PROP_application.icon_name` environment variable (user set env var value takes precedence) (breaking) +- [playback] `pulseaudio`: set values to: `PULSE_PROP_application.version`, `PULSE_PROP_application.process.binary`, `PULSE_PROP_stream.description`, `PULSE_PROP_media.software` and `PULSE_PROP_media.role` environment variables (user set env var values take precedence) (breaking) + +### Fixed + +- [connect] Don't panic when activating shuffle without previous interaction +- [core] Removed unsafe code (breaking) +- [main] Fix crash when built with Avahi support but Avahi is locally unavailable +- [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 +- [main] `--alsa-mixer-device` and `--alsa-mixer-index` now fallback to the card and index specified in `--device`. +- [playback] Adhere to ReplayGain spec when calculating gain normalisation factor +- [playback] `alsa`: make `--volume-range` overrides apply to Alsa softvol controls + +### Removed + +- [playback] `alsamixer`: previously deprecated options `mixer-card`, `mixer-name` and `mixer-index` have 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 @@ -27,6 +384,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] Add `--normalisation-gain-type auto` that switches between album and track automatically ### Changed + - [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 @@ -43,20 +401,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [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, 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] 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 - [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value @@ -80,13 +441,20 @@ 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.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 -[0.1.3]: https://github.com/librespot-org/librespot/compare/v0.1.2..v0.1.3 -[0.1.2]: https://github.com/librespot-org/librespot/compare/v0.1.1..v0.1.2 -[0.1.1]: https://github.com/librespot-org/librespot/compare/v0.1.0..v0.1.1 +[unreleased]: https://github.com/librespot-org/librespot/compare/v0.7.1...HEAD +[0.7.1]: https://github.com/librespot-org/librespot/compare/v0.7.0...v0.7.1 +[0.7.0]: https://github.com/librespot-org/librespot/compare/v0.6.0...v0.7.0 +[0.6.0]: https://github.com/librespot-org/librespot/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/librespot-org/librespot/compare/v0.4.2...v0.5.0 +[0.4.2]: https://github.com/librespot-org/librespot/compare/v0.4.1...v0.4.2 +[0.4.1]: https://github.com/librespot-org/librespot/compare/v0.4.0...v0.4.1 +[0.4.0]: https://github.com/librespot-org/librespot/compare/v0.3.1...v0.4.0 +[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 +[0.1.3]: https://github.com/librespot-org/librespot/compare/v0.1.2...v0.1.3 +[0.1.2]: https://github.com/librespot-org/librespot/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/librespot-org/librespot/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/librespot-org/librespot/releases/tag/v0.1.0 diff --git a/COMPILING.md b/COMPILING.md index 39ae20cc..ee698f62 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -7,19 +7,17 @@ In order to compile librespot, you will first need to set up a suitable Rust bui ### Install Rust The easiest, and recommended way to get Rust is to use [rustup](https://rustup.rs). Once that’s installed, Rust's standard tools should be set up and ready to use. -*Note: The current minimum required Rust version at the time of writing is 1.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) 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. +Using `cargo fmt` and `cargo clippy` is not optional, as our CI checks against this repo's rules. ### General dependencies -Along with Rust, you will also require a C compiler. - +Along with Rust, you will also require a C compiler. + On Debian/Ubuntu, install with: ```shell sudo apt-get install build-essential @@ -27,10 +25,10 @@ sudo apt-get install build-essential ``` On Fedora systems, install with: ```shell -sudo dnf install gcc +sudo dnf install gcc ``` ### Audio library dependencies -Depending on the chosen backend, specific development libraries are required. +Depending on the chosen backend, specific development libraries are required. *_Note this is an non-exhaustive list, open a PR to add to it!_* @@ -58,12 +56,91 @@ On Fedora systems: sudo dnf install alsa-lib-devel ``` +### Zeroconf library dependencies +Depending on the chosen backend, specific development libraries are required. + +*_Note this is an non-exhaustive list, open a PR to add to it!_* + +| Zeroconf backend | Debian/Ubuntu | Fedora | macOS | +|--------------------|------------------------------|-----------------------------------|-------------| +|avahi | | | | +|dns_sd | `libavahi-compat-libdnssd-dev pkg-config` | `avahi-compat-libdns_sd-devel` | | +|libmdns (default) | | | | + +### TLS library dependencies +librespot requires a TLS implementation for secure connections to Spotify's servers. You can choose between two mutually exclusive options: + +#### native-tls (default) +Uses your system's native TLS implementation: +- **Linux**: OpenSSL +- **macOS**: Secure Transport (Security.framework) +- **Windows**: SChannel (Windows TLS) + +This is the **default choice** and provides the best compatibility. It integrates with your system's certificate store and is well-tested across platforms. + +**When to choose native-tls:** +- You want maximum compatibility +- You're using system-managed certificates +- You're on a standard Linux distribution with OpenSSL +- You're deploying on platforms where OpenSSL is already present + +**Dependencies:** +On Debian/Ubuntu: +```shell +sudo apt-get install libssl-dev pkg-config +``` + +On Fedora: +```shell +sudo dnf install openssl-devel pkg-config +``` + +#### rustls-tls +Uses a Rust-based TLS implementation with certificate authority (CA) verification. Two certificate store options are available: + +**rustls-tls-native-roots**: +- **Linux**: Uses system ca-certificates package +- **macOS**: Uses Security.framework for CA verification +- **Windows**: Uses Windows certificate store +- Integrates with system certificate management and security updates + +**rustls-tls-webpki-roots**: +- Uses Mozilla's compiled-in certificate store (webpki-roots) +- Certificate trust is independent of host system +- Best for reproducible builds, containers, or embedded systems + +**When to choose rustls-tls:** +- You want to avoid external OpenSSL dependencies +- You're building for reproducible/deterministic builds +- You're targeting platforms where OpenSSL is unavailable or problematic (musl, embedded, static linking) +- You're cross-compiling and want to avoid OpenSSL build complexity +- You prefer having cryptographic operations implemented in Rust + +**No additional system dependencies required** - rustls is implemented in Rust (with some assembly for performance-critical cryptographic operations) and doesn't require external libraries like OpenSSL. + +#### Building with specific TLS backends +```bash +# Default (native-tls) +cargo build + +# Explicitly use native-tls +cargo build --no-default-features --features "native-tls rodio-backend with-libmdns" + +# Use rustls-tls with native certificate stores +cargo build --no-default-features --features "rustls-tls-native-roots rodio-backend with-libmdns" + +# Use rustls-tls with Mozilla's webpki certificate store +cargo build --no-default-features --features "rustls-tls-webpki-roots rodio-backend with-libmdns" +``` + +**Important:** The TLS backends are mutually exclusive. Attempting to enable both will result in a compile-time error. + ### Getting the Source The recommended method is to first fork the repo, so that you have a copy that you have read/write access to. After that, it’s a simple case of cloning your fork. ```bash -git clone git@github.com:YOURUSERNAME/librespot.git +git clone git@github.com:YOUR_USERNAME/librespot.git ``` ## Compiling & Running @@ -86,17 +163,21 @@ cargo build --release You will most likely want to build debug builds when developing, as they compile faster, and more verbose, and as the name suggests, are for the purposes of debugging. When submitting a bug report, it is recommended to use a debug build to capture stack traces. -There are also a number of compiler feature flags that you can add, in the event that you want to have certain additional features also compiled. The list of these is available on the [wiki](https://github.com/librespot-org/librespot/wiki/Compiling#addition-features). +There are also a number of compiler feature flags that you can add, in the event that you want to have certain additional features also compiled. All available features and their descriptions are documented in the main [Cargo.toml](Cargo.toml) file. Additional platform-specific information is available on the [wiki](https://github.com/librespot-org/librespot/wiki/Compiling#addition-features). -By default, librespot compiles with the ```rodio-backend``` feature. To compile without default features, you can run with: +By default, librespot compiles with the ```native-tls```, ```rodio-backend```, and ```with-libmdns``` features. + +**Note:** librespot requires at least one TLS backend to function. Building with `--no-default-features` alone will fail compilation. For custom feature selection, you must specify at least one TLS backend along with your desired audio and discovery backends. +For example, to build with the ALSA audio, libmdns discovery, and native-tls backends: ```bash -cargo build --no-default-features +cargo build --no-default-features --features "native-tls alsa-backend with-libmdns" ``` -Similarly, to build with the ALSA backend: +Or to use rustls-tls with ALSA: + ```bash -cargo build --no-default-features --features "alsa-backend" +cargo build --no-default-features --features "rustls-tls alsa-backend with-libmdns" ``` ### Running diff --git a/Cargo.lock b/Cargo.lock index 1651f794..0ad27773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,69 +1,70 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.6.0" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "aes-soft", - "aesni", + "cfg-if", "cipher", -] - -[[package]] -name = "aes-ctr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7729c3cde54d67063be556aeac75a81330d802f0259500ca40cb52967f975763" -dependencies = [ - "aes-soft", - "aesni", - "cipher", - "ctr", -] - -[[package]] -name = "aes-soft" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072" -dependencies = [ - "cipher", - "opaque-debug", -] - -[[package]] -name = "aesni" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce" -dependencies = [ - "cipher", - "opaque-debug", + "cpufeatures", ] [[package]] name = "aho-corasick" -version = "0.7.18" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] -name = "alsa" -version = "0.5.0" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c4da790adcb2ce5e758c064b4f3ec17a30349f9961d3e5e6c9688b052a9e18" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags", + "bitflags 2.9.4", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.9.4", + "cfg-if", "libc", - "nix", ] [[package]] @@ -77,16 +78,93 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.43" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28ae2b3dec75a406790005a200b1bd89785afc02517a00ca99ecfe093ee9e6cf" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] -name = "async-trait" -version = "0.1.51" +name = "anstream" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44318e776df68115a881de9a8fd1b9e53368d7a4a5ce4cc48517da3393233a5e" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", @@ -94,87 +172,114 @@ dependencies = [ ] [[package]] -name = "atty" -version = "0.2.14" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ - "hermit-abi", - "libc", - "winapi", + "proc-macro2", + "quote", + "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_refcell" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" + [[package]] name = "autocfg" -version = "1.0.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "base64" -version = "0.13.0" +name = "backtrace" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" - -[[package]] -name = "bindgen" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da379dbebc0b76ef63ca68d8fc6e71c0f13e59432e0987e508c1820e6ab5239" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "lazy_static", - "lazycell", - "peeking_take_while", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] -name = "bitflags" -version = "1.2.1" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "block-buffer" -version = "0.9.0" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "bumpalo" -version = "3.7.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" [[package]] name = "byteorder" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.0.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.0.69" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70cc2f62c6ce1868963827bd677764c62d07c3d9a3e1fb1177ee1a9ab199eb2" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ - "jobserver", + "find-msvc-tools", + "shlex", ] [[package]] @@ -184,164 +289,197 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] -name = "cexpr" -version = "0.4.0" +name = "cfg-expr" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27" +checksum = "1a2c5f3bf25ec225351aa1c8e230d04d880d3bd89dea133537dafad4ae291e5c" dependencies = [ - "nom", + "smallvec", + "target-lexicon", ] [[package]] name = "cfg-if" -version = "0.1.10" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] -name = "cfg-if" -version = "1.0.0" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "libc", - "num-integer", + "iana-time-zone", + "js-sys", "num-traits", - "time", - "winapi", + "serde", + "wasm-bindgen", + "windows-link 0.2.0", ] [[package]] name = "cipher" -version = "0.2.5" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "generic-array", + "crypto-common", + "inout", ] [[package]] -name = "clang-sys" -version = "1.2.0" +name = "colorchoice" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "853eda514c284c2287f4bf20ae614f8781f40a81d32ecda6e91449304dfe077c" -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", -] +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "combine" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d47c1b11006b87e492b53b313bb699ce60e16613c4dddaa91f8f7c220ab2fa" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", ] [[package]] -name = "core-foundation-sys" -version = "0.8.2" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" - -[[package]] -name = "coreaudio-rs" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11894b20ebfe1ff903cbdc52259693389eea03b94918a2def2c30c3bf227ad88" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "bitflags", - "coreaudio-sys", + "crossbeam-utils", ] [[package]] -name = "coreaudio-sys" -version = "0.2.8" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b7e3347be6a09b46aba228d6608386739fb70beff4f61e07422da87b0bb31fa" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "bindgen", + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] name = "cpal" -version = "0.13.4" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98f45f0a21f617cd2c788889ef710b63f075c949259593ea09c826f1e47a2418" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" dependencies = [ - "alsa", - "core-foundation-sys", + "alsa 0.9.1", "coreaudio-rs", + "dasp_sample", "jack", "jni", "js-sys", - "lazy_static", "libc", - "mach", - "ndk 0.3.0", - "ndk-glue 0.3.0", - "nix", - "oboe", - "parking_lot", - "stdweb", - "thiserror", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", - "winapi", + "windows 0.54.0", ] [[package]] name = "cpufeatures" -version = "0.1.5" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] -name = "crypto-mac" -version = "0.11.1" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "subtle", + "typenum", ] [[package]] name = "ctr" -version = "0.6.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ "cipher", ] [[package]] name = "darling" -version = "0.10.2" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -349,9 +487,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.10.2" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", @@ -363,9 +501,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.10.2" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", @@ -373,23 +511,99 @@ dependencies = [ ] [[package]] -name = "derivative" -version = "2.2.0" +name = "dasp_sample" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", "proc-macro2", "quote", "syn", ] [[package]] -name = "digest" -version = "0.9.0" +name = "derive_builder_macro" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ - "generic-array", + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.4", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -404,21 +618,125 @@ dependencies = [ [[package]] name = "either" -version = "1.6.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", +] [[package]] name = "env_logger" -version = "0.8.4" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ - "atty", - "humantime", + "anstream", + "anstyle", + "env_filter", + "jiff", "log", - "regex", - "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", ] [[package]] @@ -428,20 +746,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "form_urlencoded" -version = "1.0.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "matches", "percent-encoding", ] [[package]] name = "futures" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adc00f486adfc9ce99f77d717836f0c5aa84965eb0b4f051f4e83f7cab53f8b" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -454,9 +792,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74ed2411805f6e4e3d9bc904c95d5d423b89b3b25dc0250aa74729de20629ff9" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -464,15 +802,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af51b1b4a7fdff033703db39de8802c673eb91855f2e0d47dcf3bf2c0ef01f99" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d0d535a57b87e1ae31437b892713aee90cd2d7b0ee48727cd11fc72ef54761c" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -481,18 +819,29 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0e06c393068f3a6ef246c75cdca793d6a46347e75286933e5e75fd2fd11582" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] [[package]] name = "futures-macro" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c54913bae956fb8df7f4dc6fc90362aa72e69148e3f39041fbe8742d21e0ac57" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ - "autocfg", - "proc-macro-hack", "proc-macro2", "quote", "syn", @@ -500,23 +849,28 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f30aaa67363d119812743aa5f33c201a7a66329f97d1a887022971feea4b53" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe54a98670017f3be909561f6ad13e810d9a51f3f061b902062ca3da80799f2" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.16" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eb846bfd58e44a8481a00049e82c43e0ccb5d61f8dc071057cb19249dd4d78" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "autocfg", "futures-channel", "futures-core", "futures-io", @@ -526,16 +880,14 @@ dependencies = [ "memchr", "pin-project-lite", "pin-utils", - "proc-macro-hack", - "proc-macro-nested", "slab", ] [[package]] name = "generic-array" -version = "0.14.4" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -543,54 +895,88 @@ dependencies = [ [[package]] name = "getopts" -version = "0.2.21" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width", ] [[package]] name = "getrandom" -version = "0.2.3" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", + "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gio-sys" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171ed2f6dd927abbe108cfd9eebff2052c335013f5879d55bab0dc1dee19b706" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.61.0", ] [[package]] name = "glib" -version = "0.10.3" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c685013b7515e668f1b57a165b009d4d28cb139a8a989bbd699c10dad29d0c5" +checksum = "e1f2cbc4577536c849335878552f42086bfd25a8dcd6f54a18655cf818b20c8f" dependencies = [ - "bitflags", + "bitflags 2.9.4", "futures-channel", "futures-core", "futures-executor", "futures-task", "futures-util", + "gio-sys", "glib-macros", "glib-sys", "gobject-sys", "libc", - "once_cell", + "memchr", + "smallvec", ] [[package]] name = "glib-macros" -version = "0.10.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41486a26d1366a8032b160b59065a59fb528530a46a49f627e7048fb8c064039" +checksum = "55eda916eecdae426d78d274a17b48137acdca6fba89621bd3705f2835bc719f" dependencies = [ - "anyhow", "heck", - "itertools", - "proc-macro-crate 0.1.5", - "proc-macro-error", + "proc-macro-crate", "proc-macro2", "quote", "syn", @@ -598,80 +984,89 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.10.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e9b997a66e9a23d073f2b1abb4dbfc3925e0b8952f67efd8d9b6e168e4cdc1" +checksum = "d09d3d0fddf7239521674e57b0465dfbd844632fec54f059f7f56112e3f927e1" dependencies = [ "libc", "system-deps", ] -[[package]] -name = "glob" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" - [[package]] name = "gobject-sys" -version = "0.10.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "952133b60c318a62bf82ee75b93acc7e84028a093e06b9e27981c2b6fe68218c" +checksum = "538e41d8776173ec107e7b0f2aceced60abc368d7e1d81c1f0e2ecd35f59080d" dependencies = [ "glib-sys", "libc", "system-deps", ] +[[package]] +name = "governor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444405bbb1a762387aa22dd569429533b54a1d8759d35d3b64cb39b0293eaa19" +dependencies = [ + "cfg-if", + "futures-sink", + "futures-timer", + "futures-util", + "hashbrown 0.15.5", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "gstreamer" -version = "0.16.7" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff5d0f7ff308ae37e6eb47b6ded17785bdea06e438a708cd09e0288c1862f33" +checksum = "3e7ba7a2584e31927b7fec6a32737b57dc991b55253c9bb7c2c8eddb5a4cb345" dependencies = [ - "bitflags", - "cfg-if 1.0.0", + "cfg-if", "futures-channel", "futures-core", "futures-util", "glib", - "glib-sys", - "gobject-sys", "gstreamer-sys", + "itertools", + "kstring", "libc", "muldiv", + "num-integer", "num-rational", - "once_cell", - "paste", - "pretty-hex", - "thiserror", + "option-operations", + "pastey", + "pin-project-lite", + "smallvec", + "thiserror 2.0.16", ] [[package]] name = "gstreamer-app" -version = "0.16.5" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc80888271338c3ede875d8cafc452eb207476ff5539dcbe0018a8f5b827af0e" +checksum = "0af5d403738faf03494dfd502d223444b4b44feb997ba28ab3f118ee6d40a0b2" dependencies = [ - "bitflags", "futures-core", "futures-sink", "glib", - "glib-sys", - "gobject-sys", "gstreamer", "gstreamer-app-sys", "gstreamer-base", - "gstreamer-sys", "libc", - "once_cell", ] [[package]] name = "gstreamer-app-sys" -version = "0.9.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "813f64275c9e7b33b828b9efcf9dfa64b95996766d4de996e84363ac65b87e3d" +checksum = "aaf1a3af017f9493c34ccc8439cbce5c48f6ddff6ec0514c23996b374ff25f9a" dependencies = [ "glib-sys", "gstreamer-base-sys", @@ -680,27 +1075,54 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gstreamer-audio" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e540174d060cd0d7ee2c2356f152f05d8262bf102b40a5869ff799377269d8" +dependencies = [ + "cfg-if", + "glib", + "gstreamer", + "gstreamer-audio-sys", + "gstreamer-base", + "libc", + "smallvec", +] + +[[package]] +name = "gstreamer-audio-sys" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626cd3130bc155a8b6d4ac48cfddc15774b5a6cc76fcb191aab09a2655bad8f5" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + [[package]] name = "gstreamer-base" -version = "0.16.5" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bafd01c56f59cb10f4b5a10f97bb4bdf8c2b2784ae5b04da7e2d400cf6e6afcf" +checksum = "71ff9b0bbc8041f0c6c8a53b206a6542f86c7d9fa8a7dff3f27d9c374d9f39b4" dependencies = [ - "bitflags", + "atomic_refcell", + "cfg-if", "glib", - "glib-sys", - "gobject-sys", "gstreamer", "gstreamer-base-sys", - "gstreamer-sys", "libc", ] [[package]] name = "gstreamer-base-sys" -version = "0.9.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4b7b6dc2d6e160a1ae28612f602bd500b3fa474ce90bf6bb2f08072682beef5" +checksum = "fed78852b92db1459b8f4288f86e6530274073c20be2f94ba642cddaca08b00e" dependencies = [ "glib-sys", "gobject-sys", @@ -711,10 +1133,11 @@ dependencies = [ [[package]] name = "gstreamer-sys" -version = "0.9.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1f154082d01af5718c5f8a8eb4f565a4ea5586ad8833a8fc2c2aa6844b601d" +checksum = "a24ae2930e683665832a19ef02466094b09d1f2da5673f001515ed5486aa9377" dependencies = [ + "cfg-if", "glib-sys", "gobject-sys", "libc", @@ -722,53 +1145,70 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.11.2" +name = "h2" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" [[package]] name = "headers" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0b7591fb62902706ae8e7aaff416b1b0fa2c0fd0878b46dc13baa3712d8a855" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ "base64", - "bitflags", "bytes", "headers-core", "http", + "httpdate", "mime", - "sha-1", - "time", + "sha1", ] [[package]] name = "headers-core" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ "http", ] [[package]] name = "heck" -version = "0.3.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" @@ -778,30 +1218,38 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hmac" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "crypto-mac", "digest", ] [[package]] -name = "hostname" -version = "0.3.1" +name = "home" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", "libc", - "match_cfg", - "winapi", + "windows-link 0.1.3", ] [[package]] name = "http" -version = "0.2.4" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527e8c9ac747e28542699a951517aa9a6945af506cd1f2e1b53a576c17b6cc11" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -810,69 +1258,275 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399c583b2979440c60be0821a6199eca73bc3c8dcd9d070d75ac726e2c6186e5" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", "pin-project-lite", ] [[package]] name = "httparse" -version = "1.5.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440" - -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "0.14.11" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b61cf2d1aebcf6e6352c97b81dc2244ca29194be1b276f5d8ad5c6330fffb11" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", "futures-core", - "futures-util", + "h2", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2", + "pin-utils", + "smallvec", "tokio", - "tower-service", - "tracing", "want", ] [[package]] -name = "hyper-proxy" -version = "0.9.1" +name = "hyper-proxy2" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca815a891b24fdfb243fa3239c86154392b0953ee584aa1a2a1f66d20cbe75cc" +checksum = "9043b7b23fb0bc4a1c7014c27b50a4fc42cc76206f71d34fc0dfe5b28ddc3faf" dependencies = [ "bytes", - "futures", + "futures-util", "headers", "http", "hyper", + "hyper-rustls 0.26.0", + "hyper-tls", + "hyper-util", + "native-tls", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.25.0", + "tower-service", + "webpki", + "webpki-roots 0.26.11", +] + +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "log", + "rustls 0.22.4", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", + "webpki-roots 0.26.11", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.32", + "rustls-native-certs 0.8.1", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.3", + "tower-service", + "webpki-roots 1.0.2", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", "tokio", "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] @@ -883,105 +1537,186 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.2.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "matches", - "unicode-bidi", - "unicode-normalization", + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] name = "if-addrs" -version = "0.6.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28538916eb3f3976311f5dfbe67b5362d0add1293d0a9cad17debf86f8e3aa48" +checksum = "bf39cc0423ee66021dc5eccface85580e4a001e0c5288bae8bea7ecb69225e90" dependencies = [ - "if-addrs-sys", - "libc", - "winapi", -] - -[[package]] -name = "if-addrs-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de74b9dd780476e837e5eb5ab7c88b49ed304126e412030a0adba99c8efe79ea" -dependencies = [ - "cc", "libc", + "windows-sys 0.59.0", ] [[package]] name = "indexmap" -version = "1.7.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ - "autocfg", - "hashbrown", + "equivalent", + "hashbrown 0.16.0", ] [[package]] -name = "instant" -version = "0.1.10" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee0328b1209d157ef001c94dd85b4f8f64139adb0eac2659f4b08382b2f474d" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "cfg-if 1.0.0", + "generic-array", ] +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itertools" -version = "0.9.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" -version = "0.4.7" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jack" -version = "0.7.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e720259b4a3e1f33cba335ca524a99a5f2411d405b05f6405fadd69269e2db" +checksum = "f70ca699f44c04a32d419fc9ed699aaea89657fc09014bf3fa238e91d13041b9" dependencies = [ - "bitflags", + "bitflags 2.9.4", "jack-sys", "lazy_static", "libc", + "log", ] [[package]] name = "jack-sys" -version = "0.2.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57983f0d72dfecf2b719ed39bc9cacd85194e1a94cb3f9146009eff9856fef41" +checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab" dependencies = [ + "bitflags 1.3.2", "lazy_static", "libc", - "libloading 0.6.7", + "libloading", + "log", + "pkg-config", +] + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "jni" -version = "0.19.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", + "cfg-if", "combine", "jni-sys", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", + "windows-sys 0.45.0", ] [[package]] @@ -990,84 +1725,61 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" -dependencies = [ - "libc", -] - [[package]] name = "js-sys" -version = "0.3.53" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4bf49d50e2961077d9c99f4b7997d770a1114f087c3c2e0069b36c13fc2979d" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ + "once_cell", "wasm-bindgen", ] [[package]] -name = "lazy_static" -version = "1.4.0" +name = "kstring" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - -[[package]] -name = "lewton" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" dependencies = [ - "byteorder", - "ogg", - "tinyvec", + "static_assertions", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", ] [[package]] name = "libc" -version = "0.2.99" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libloading" -version = "0.6.7" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "351a32417a12d5f7e82c368a66781e307834dae04c6ce0cd4456d52989229883" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" dependencies = [ - "cfg-if 1.0.0", - "winapi", -] - -[[package]] -name = "libloading" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f84d96438c15fcd6c3f244c8fce01d1e2b9c6b5623e9c711dc9286d8fc92d6a" -dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "winapi", ] [[package]] name = "libm" -version = "0.2.1" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libmdns" -version = "0.6.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98477a6781ae1d6a1c2aeabfd2e23353a75fe8eb7c2545f6ed282ac8f3e2fc53" +checksum = "a00dbe871d2cf9df13f68d152b949fca8cafc854b60ffd259fc6df6e8663d8d7" dependencies = [ "byteorder", "futures-util", @@ -1075,19 +1787,19 @@ dependencies = [ "if-addrs", "log", "multimap", - "rand", + "rand 0.9.2", "socket2", - "thiserror", + "thiserror 2.0.16", "tokio", ] [[package]] name = "libpulse-binding" -version = "2.24.0" +version = "2.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04b4154b9bc606019cb15125f96e08e1e9c4f53d55315f1ef69ae229e30d1765" +checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" dependencies = [ - "bitflags", + "bitflags 2.9.4", "libc", "libpulse-sys", "num-derive", @@ -1097,9 +1809,9 @@ dependencies = [ [[package]] name = "libpulse-simple-binding" -version = "2.24.0" +version = "2.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165af13c42b9c325582b1a75eaa4a0f176c9094bb3a13877826e9be24881231" +checksum = "b7bebef0381c8e3e4b23cc24aaf36fab37472bece128de96f6a111efa464cfef" dependencies = [ "libpulse-binding", "libpulse-simple-sys", @@ -1108,9 +1820,9 @@ dependencies = [ [[package]] name = "libpulse-simple-sys" -version = "1.19.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83346d68605e656afdefa9a8a2f1968fa05ab9369b55f2e26f7bf2a11b7e8444" +checksum = "3bd96888fe37ad270d16abf5e82cccca1424871cf6afa2861824d2a52758eebc" dependencies = [ "libpulse-sys", "pkg-config", @@ -1118,9 +1830,9 @@ dependencies = [ [[package]] name = "libpulse-sys" -version = "1.19.1" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ebed2cc92c38cac12307892ce6fb17e2e950bfda1ed17b3e1d47fd5184c8f2b" +checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" dependencies = [ "libc", "num-derive", @@ -1131,153 +1843,192 @@ dependencies = [ [[package]] name = "librespot" -version = "0.3.1" +version = "0.7.1" dependencies = [ - "base64", + "data-encoding", "env_logger", "futures-util", "getopts", - "hex", - "hyper", "librespot-audio", "librespot-connect", "librespot-core", "librespot-discovery", "librespot-metadata", + "librespot-oauth", "librespot-playback", "librespot-protocol", "log", - "rpassword", - "sha-1", - "thiserror", + "sha1", + "sysinfo", + "thiserror 2.0.16", "tokio", "url", ] [[package]] name = "librespot-audio" -version = "0.3.1" +version = "0.7.1" dependencies = [ - "aes-ctr", - "byteorder", + "aes", "bytes", + "ctr", "futures-util", + "http-body-util", + "hyper", + "hyper-util", "librespot-core", "log", "tempfile", + "thiserror 2.0.16", "tokio", ] [[package]] name = "librespot-connect" -version = "0.3.1" +version = "0.7.1" dependencies = [ - "form_urlencoded", "futures-util", "librespot-core", - "librespot-discovery", "librespot-playback", "librespot-protocol", "log", "protobuf", - "rand", - "serde", + "rand 0.9.2", "serde_json", + "thiserror 2.0.16", "tokio", "tokio-stream", + "uuid", ] [[package]] name = "librespot-core" -version = "0.3.1" +version = "0.7.1" dependencies = [ "aes", "base64", "byteorder", "bytes", - "env_logger", + "data-encoding", + "flate2", "form_urlencoded", "futures-core", "futures-util", + "governor", "hmac", "http", + "http-body-util", "httparse", "hyper", - "hyper-proxy", + "hyper-proxy2", + "hyper-rustls 0.27.7", + "hyper-tls", + "hyper-util", + "librespot-oauth", "librespot-protocol", "log", + "nonzero_ext", "num-bigint", + "num-derive", "num-integer", "num-traits", - "once_cell", "pbkdf2", + "pin-project-lite", "priority-queue", "protobuf", - "rand", + "protobuf-json-mapping", + "quick-xml", + "rand 0.9.2", + "rand_distr", + "rsa", "serde", "serde_json", - "sha-1", + "sha1", "shannon", - "thiserror", + "sysinfo", + "thiserror 2.0.16", + "time", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "url", "uuid", - "vergen", + "vergen-gitcl", ] [[package]] name = "librespot-discovery" -version = "0.3.1" +version = "0.7.1" dependencies = [ - "aes-ctr", + "aes", "base64", - "cfg-if 1.0.0", + "bytes", + "ctr", "dns-sd", "form_urlencoded", "futures", "futures-core", + "futures-util", "hex", "hmac", + "http-body-util", "hyper", + "hyper-util", "libmdns", "librespot-core", "log", - "rand", + "rand 0.9.2", + "serde", "serde_json", - "sha-1", - "simple_logger", - "thiserror", + "serde_repr", + "sha1", + "thiserror 2.0.16", "tokio", + "zbus", ] [[package]] name = "librespot-metadata" -version = "0.3.1" +version = "0.7.1" dependencies = [ "async-trait", - "byteorder", + "bytes", "librespot-core", "librespot-protocol", "log", "protobuf", + "serde", + "serde_json", + "thiserror 2.0.16", + "uuid", +] + +[[package]] +name = "librespot-oauth" +version = "0.7.1" +dependencies = [ + "env_logger", + "log", + "oauth2", + "open", + "reqwest", + "thiserror 2.0.16", + "tokio", + "url", ] [[package]] name = "librespot-playback" -version = "0.3.1" +version = "0.7.1" dependencies = [ - "alsa", - "byteorder", + "alsa 0.10.0", "cpal", - "futures-executor", "futures-util", - "glib", "gstreamer", "gstreamer-app", + "gstreamer-audio", "jack", - "lewton", "libpulse-binding", "libpulse-simple-binding", "librespot-audio", @@ -1285,244 +2036,241 @@ dependencies = [ "librespot-metadata", "log", "ogg", + "portable-atomic", "portaudio-rs", - "rand", + "rand 0.9.2", "rand_distr", "rodio", "sdl2", "shell-words", - "thiserror", + "symphonia", + "thiserror 2.0.16", "tokio", "zerocopy", ] [[package]] name = "librespot-protocol" -version = "0.3.1" +version = "0.7.1" dependencies = [ - "glob", "protobuf", - "protobuf-codegen-pure", + "protobuf-codegen", ] [[package]] -name = "lock_api" -version = "0.4.4" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ + "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.14" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" -dependencies = [ - "cfg-if 1.0.0", -] +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] -name = "mach" -version = "0.3.2" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] -[[package]] -name = "match_cfg" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" - -[[package]] -name = "matches" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" - [[package]] name = "memchr" -version = "2.4.1" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memoffset" -version = "0.6.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59accc507f1338036a0477ef61afdae33cde60840f4dfe481319ce3ad116ddf9" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "mime" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "mio" -version = "0.7.13" +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "libc", - "log", - "miow", - "ntapi", - "winapi", + "adler2", ] [[package]] -name = "miow" -version = "0.3.7" +name = "mio" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ - "winapi", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", ] [[package]] name = "muldiv" -version = "0.2.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0419348c027fa7be448d2ae7ea0e4e04c2334c31dc4e74ab29f00a2a7ca69204" +checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" [[package]] name = "multimap" -version = "0.8.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ - "serde", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", ] [[package]] name = "ndk" -version = "0.3.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8794322172319b972f528bf90c6b467be0079f1fa82780ffb431088e741a73ab" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ + "bitflags 2.9.4", "jni-sys", + "log", "ndk-sys", "num_enum", - "thiserror", + "thiserror 1.0.69", ] [[package]] -name = "ndk" -version = "0.4.0" +name = "ndk-context" +version = "0.1.1" 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5caf0c24d51ac1c905c27d4eda4fa0635bbe0de596b8f79235e0b17a4d29385" -dependencies = [ - "lazy_static", - "libc", - "log", - "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", -] - -[[package]] -name = "ndk-macro" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d1c6307dc424d0f65b9b06e94f88248e6305726b14729fd67a5e47b2dc481d" -dependencies = [ - "darling", - "proc-macro-crate 0.1.5", - "proc-macro2", - "quote", - "syn", -] +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "ndk-sys" -version = "0.2.1" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c44922cb3dbb1c70b5e5f443d63b64363a898564d739ba5198e3a9138442868d" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] [[package]] name = "nix" -version = "0.20.1" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8e5e343312e7fbeb2a52139114e9e702991ef9c2aea6817ff2440b35647d56" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", - "cc", - "cfg-if 1.0.0", + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", "libc", "memoffset", ] [[package]] -name = "nom" -version = "5.1.2" +name = "nonzero_ext" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" -dependencies = [ - "memchr", - "version_check", -] +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "ntapi" -version = "0.3.6" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" dependencies = [ "winapi", ] [[package]] name = "num-bigint" -version = "0.4.2" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74e768dff5fb39a41b3bcd30bb25cf989706c90d028d1ad71971987aa309d535" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "autocfg", "num-integer", "num-traits", - "rand", ] [[package]] -name = "num-derive" -version = "0.3.3" +name = "num-bigint-dig" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", @@ -1531,19 +2279,18 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.44" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "autocfg", "num-traits", ] [[package]] -name = "num-rational" -version = "0.3.2" +name = "num-iter" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", @@ -1551,149 +2298,334 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.14" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", "libm", ] -[[package]] -name = "num_cpus" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "num_enum" -version = "0.5.4" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9bd055fb730c4f8f4f57d45d35cd6b3f0980535b056dc7ff119cee6a66ed6f" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" dependencies = [ - "derivative", "num_enum_derive", + "rustversion", ] [[package]] name = "num_enum_derive" -version = "0.5.4" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "486ea01961c4a818096de679a8b740b26d9033146ac5291b1c98557658f8cdd9" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ - "proc-macro-crate 1.0.0", + "proc-macro-crate", "proc-macro2", "quote", "syn", ] [[package]] -name = "oboe" -version = "0.4.4" +name = "num_threads" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e15e22bc67e047fe342a32ecba55f555e3be6166b04dd157cd0f803dfa9f48e1" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ - "jni", - "ndk 0.4.0", - "ndk-glue 0.4.0", - "num-derive", - "num-traits", - "oboe-sys", + "libc", ] [[package]] -name = "oboe-sys" -version = "0.4.4" +name = "oauth2" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338142ae5ab0aaedc8275aa8f67f460e43ae0fca76a695a742d56da0a269eadc" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "cc", + "base64", + "chrono", + "getrandom 0.2.16", + "http", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "objc2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" +dependencies = [ + "bitflags 2.9.4", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" +dependencies = [ + "bitflags 2.9.4", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", ] [[package]] name = "ogg" -version = "0.8.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +checksum = "fdab8dcd8d4052eaacaf8fb07a3ccd9a6e26efadb42878a413c68fc4af1dee2b" dependencies = [ "byteorder", ] [[package]] name = "once_cell" -version = "1.8.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "opaque-debug" -version = "0.3.0" +name = "once_cell_polyfill" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "open" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-operations" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b31ce827892359f23d3cd1cc4c75a6c241772bbd2db17a92dcf27cbefdf52689" +dependencies = [ + "pastey", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.11.1" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ - "instant", "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" -version = "0.8.3" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ - "cfg-if 1.0.0", - "instant", + "cfg-if", "libc", "redox_syscall", "smallvec", - "winapi", + "windows-targets 0.52.6", ] [[package]] -name = "paste" -version = "1.0.5" +name = "pastey" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf547ad0c65e31259204bd90935776d1c693cec2f4ff7abb7a1bbbd40dfe58" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pbkdf2" -version = "0.8.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95f5254224e617595d2cc3cc73ff0a5eaf2637519e25f03388154e9378b6ffa" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "crypto-mac", + "digest", "hmac", ] [[package]] -name = "peeking_take_while" -version = "0.1.2" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] [[package]] name = "percent-encoding" -version = "2.1.0" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.7" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -1702,10 +2634,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "pkg-config" -version = "0.3.19" +name = "pkcs1" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] [[package]] name = "portaudio-rs" @@ -1713,7 +2681,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb6b5eff96ccc9bf44d34c379ab03ae944426d83d1694345bdf8159d561d562" dependencies = [ - "bitflags", + "bitflags 1.3.2", "libc", "portaudio-sys", ] @@ -1729,135 +2697,218 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" -version = "0.2.10" +name = "potential_utf" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] [[package]] -name = "pretty-hex" -version = "0.2.1" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5c99d529f0d30937f6f4b8a86d988047327bb88d04d2c4afc356de74722131" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] name = "priority-queue" -version = "1.1.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1340009a04e81f656a4e45e295f0b1191c81de424bf940c865e33577a8e223" +checksum = "3e7f4ffd8645efad783fc2844ac842367aa2e912d484950192564d57dc039a3a" dependencies = [ - "autocfg", + "equivalent", "indexmap", ] [[package]] name = "proc-macro-crate" -version = "0.1.5" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml", + "toml_edit 0.23.6", ] -[[package]] -name = "proc-macro-crate" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fdbd1df62156fbc5945f4762632564d7d038153091c3fcf1067f6aef7cff92" -dependencies = [ - "thiserror", - "toml", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" - -[[package]] -name = "proc-macro-nested" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086" - [[package]] name = "proc-macro2" -version = "1.0.28" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] name = "protobuf" -version = "2.25.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020f86b07722c5c4291f7c723eac4676b3892d47d9a7708dc2779696407f039b" - -[[package]] -name = "protobuf-codegen" -version = "2.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b8ac7c5128619b0df145d9bace18e8ed057f18aebda1aa837a5525d4422f68c" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" dependencies = [ - "protobuf", + "once_cell", + "protobuf-support", + "thiserror 1.0.69", ] [[package]] -name = "protobuf-codegen-pure" -version = "2.25.0" +name = "protobuf-codegen" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d0daa1b61d6e7a128cdca8c8604b3c5ee22c424c15c8d3a92fafffeda18aaf" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-json-mapping" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d6e4be637b310d8a5c02fa195243328e2d97fa7df1127a27281ef1187fcb1d" dependencies = [ "protobuf", - "protobuf-codegen", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.32", + "socket2", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.32", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.9" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] [[package]] -name = "rand" -version = "0.8.4" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", - "rand_hc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1867,51 +2918,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] name = "rand_core" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", ] [[package]] name = "rand_distr" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964d548f8e7d12e102ef183a0de7e98180c9f8729f555897a857b96e48122d2f" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand", -] - -[[package]] -name = "rand_hc" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" -dependencies = [ - "rand_core", + "rand 0.9.2", ] [[package]] name = "redox_syscall" -version = "0.2.10" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags", + "bitflags 2.9.4", ] [[package]] name = "regex" -version = "1.5.4" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", @@ -1920,58 +2993,242 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.25" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] -name = "remove_dir_all" -version = "0.5.3" +name = "reqwest" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ - "winapi", + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls 0.27.7", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.32", + "rustls-native-certs 0.8.1", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.3", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] name = "rodio" -version = "0.14.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d98f5e557b61525057e2bc142c8cd7f0e70d75dc32852309bec440e6e046bf9" +checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" dependencies = [ "cpal", + "dasp_sample", + "num-rational", ] [[package]] -name = "rpassword" -version = "5.0.1" +name = "rsa" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" dependencies = [ - "libc", - "winapi", + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", ] +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + [[package]] name = "rustc-hash" -version = "1.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] -name = "rustc_version" -version = "0.4.0" +name = "rustix" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "semver", + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", ] [[package]] -name = "ryu" -version = "1.0.5" +name = "rustix" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.0", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.6", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.4.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "same-file" @@ -1983,18 +3240,27 @@ dependencies = [ ] [[package]] -name = "scopeguard" -version = "1.1.0" +name = "schannel" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdl2" -version = "0.34.5" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deecbc3fa9460acff5a1e563e05cb5f31bba0aa0c214bb49a43db8159176d54b" +checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" dependencies = [ - "bitflags", + "bitflags 1.3.2", "lazy_static", "libc", "sdl2-sys", @@ -2002,35 +3268,75 @@ dependencies = [ [[package]] name = "sdl2-sys" -version = "0.34.5" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a29aa21f175b5a41a6e26da572d5e5d1ee5660d35f9f9d0913e8a802098f74" +checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" dependencies = [ - "cfg-if 0.1.10", + "cfg-if", "libc", - "version-compare", + "version-compare 0.1.1", ] [[package]] -name = "semver" -version = "1.0.4" +name = "security-framework" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "568a8e6258aa33c13358f81fd834adb854c6f7c9468520910a9b1e8fac068012" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] name = "serde" -version = "1.0.127" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03b9878abf6d14e6779d3f24f07b2cfa90352cfec4acc5aab8f1ac7f146fae8" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.127" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a024926d3432516606328597e0f224a51355a493b49fdd67e9209187cbe55ecc" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", @@ -2039,26 +3345,80 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.66" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b10da19a12ad094b59d870ebde26a45402e5b470add4b5fd03c5048a32127" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", "itoa", "ryu", "serde", ] [[package]] -name = "sha-1" -version = "0.9.7" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a0c8611594e2ab4ebbf06ec7cbbf0a99450b8570e96cbf5188b5d5f6ef18d81" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "block-buffer", - "cfg-if 1.0.0", + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", "cpufeatures", "digest", - "opaque-debug", ] [[package]] @@ -2072,171 +3432,322 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fa3938c99da4914afedd13bf3d79bcb6c277d1b2c398d23257a304d9e1b074" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" [[package]] name = "shlex" -version = "0.1.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fdf1b9db47230893d76faad238fd6097fd6d6a9245cd7a4d90dbd639536bbd2" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.0" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] [[package]] -name = "simple_logger" -version = "1.13.0" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7de33c687404ec3045d4a0d437580455257c0436f858d702f244e7d652f9f07" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "atty", - "chrono", - "colored", - "log", - "winapi", + "digest", + "rand_core 0.6.4", ] [[package]] name = "slab" -version = "0.4.4" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c307a32c1c5c437f38c7fd45d753050587732ba8628319fbdf12a7e289ccc590" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.6.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.4.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765f090f0e423d2b55843402a07915add955e7d60657db13707a159727326cad" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "winapi", + "windows-sys 0.59.0", ] [[package]] -name = "stdweb" -version = "0.1.3" +name = "spin" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef5430c8e36b713e13b48a9f709cc21e046723fe44ce34587b73a830203b533e" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" -version = "0.9.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" - -[[package]] -name = "strum" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57bd81eb48f4c437cadc685403cad539345bf703d78e63707418431cecd4522b" - -[[package]] -name = "strum_macros" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87c85aa3f8ea653bfd3ddf25f7ee357ee4d204731f6aa9ad04002306f6e2774c" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.4.1" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] [[package]] name = "syn" -version = "1.0.74" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", ] [[package]] name = "synstructure" -version = "0.12.5" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "474aaa926faa1603c40b7885a9eaea29b444d1cb2850cb7c0e37bb1a4182f4fa" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn", - "unicode-xid", +] + +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] name = "system-deps" -version = "1.3.2" +version = "7.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3ecc17269a19353b3558b313bba738b25d82993e30d62a18406a24aba4649b" +checksum = "e4be53aa0cba896d2dc615bd42bbc130acdcffa239e0a2d965ea5b3b2a86ffdb" dependencies = [ + "cfg-expr", "heck", "pkg-config", - "strum", - "strum_macros", - "thiserror", "toml", - "version-compare", + "version-compare 0.2.0", ] +[[package]] +name = "target-lexicon" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" + [[package]] name = "tempfile" -version = "3.2.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ - "cfg-if 1.0.0", - "libc", - "rand", - "redox_syscall", - "remove_dir_all", - "winapi", -] - -[[package]] -name = "termcolor" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" -dependencies = [ - "winapi-util", + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.0", ] [[package]] name = "thiserror" -version = "1.0.26" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93119e4feac1cbe6c798c34d3a53ea0026b0b1de6a120deef895137c0529bfe2" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", ] [[package]] name = "thiserror-impl" -version = "1.0.26" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "060d69a0afe7796bf42e9e2ff91f5ee691fb15c53d38b4b62a9a53eb23164745" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -2245,53 +3756,87 @@ dependencies = [ [[package]] name = "time" -version = "0.1.43" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ + "deranged", + "itoa", "libc", - "winapi", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] name = "tinyvec" -version = "1.3.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "848a1e1181b9f6753b5e96a092749e29b11d19ede67dfbbd6c7dc7e0f49b5338" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.10.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cf844b23c6131f624accf65ce0e4e9956a8bb329400ea5bcc26ae3a5c20b0b" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ - "autocfg", + "backtrace", "bytes", + "io-uring", "libc", - "memchr", "mio", - "num_cpus", - "once_cell", "pin-project-lite", "signal-hook-registry", + "slab", + "socket2", "tokio-macros", - "winapi", + "tracing", + "windows-sys 0.59.0", ] [[package]] name = "tokio-macros" -version = "1.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54473be61f4ebe4efd09cec9bd5d16fa51d70ea0192213d754d2d500457db110" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", @@ -2299,10 +3844,41 @@ dependencies = [ ] [[package]] -name = "tokio-stream" -version = "0.1.7" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2f3f698253f03119ac0102beaa64f67a67e08074d03a22d18784104543727f" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" +dependencies = [ + "rustls 0.23.32", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", @@ -2310,188 +3886,402 @@ dependencies = [ ] [[package]] -name = "tokio-util" -version = "0.6.7" +name = "tokio-tungstenite" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1caa0b0c8d94a049db56b5acf8cba99dc0623aab1b26d5b5f5e2d945846b3592" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "native-tls", + "rustls 0.23.32", + "rustls-native-certs 0.8.1", + "rustls-pki-types", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.3", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", "futures-sink", - "log", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "0.5.8" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] -name = "tower-service" -version = "0.3.1" +name = "toml_datetime" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +dependencies = [ + "indexmap", + "toml_datetime 0.7.2", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.26" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09adeb8c97449311ccd28a427f96fb563e7fd31aabf994189879d9da2394b89d" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "cfg-if 1.0.0", "pin-project-lite", + "tracing-attributes", "tracing-core", ] [[package]] -name = "tracing-core" -version = "0.1.19" +name = "tracing-attributes" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca517f43f0fb96e0c3072ed5c275fe5eece87e8cb52f4a77b69226d3b1c9df8" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ - "lazy_static", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", ] [[package]] name = "try-lock" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "rustls 0.23.32", + "rustls-pki-types", + "sha1", + "thiserror 2.0.16", + "utf-8", +] [[package]] name = "typenum" -version = "1.13.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] -name = "unicode-bidi" -version = "0.3.6" +name = "uds_windows" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "246f4c42e67e7a4e3c6106ff716a5d067d4132a642840b242e357e468a2a0085" - -[[package]] -name = "unicode-normalization" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "tinyvec", + "memoffset", + "tempfile", + "winapi", ] [[package]] -name = "unicode-segmentation" -version = "1.8.0" +name = "unicode-ident" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-width" -version = "0.1.8" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] -name = "unicode-xid" -version = "0.2.2" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.2.2" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", - "matches", "percent-encoding", + "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" -version = "0.8.2" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom", + "getrandom 0.3.3", ] [[package]] -name = "vergen" -version = "3.2.0" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7141e445af09c8919f1d5f8a20dae0b20c3b57a45dee0d5823c6ed5d237f15a" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "9.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b2bf58be11fc9414104c6d3a2e464163db5ef74b12296bda593cac37b6e4777" dependencies = [ - "bitflags", - "chrono", - "rustc_version", + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", ] [[package]] name = "version-compare" -version = "0.0.10" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d63556a25bae6ea31b52e640d7c41d1ab27faba4ccb600013837a3d0b3994ca1" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" [[package]] name = "version_check" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "walkdir" -version = "2.3.2" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", - "winapi", "winapi-util", ] [[package]] name = "want" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "log", "try-lock", ] [[package]] name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] [[package]] name = "wasm-bindgen" -version = "0.2.76" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce9b1b516211d33767048e5d47fa2a381ed8b76fc48d2ce4aa39877f9f183e0" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.76" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe8dc78e2326ba5f845f4b5bf548401604fa20b1dd1d365fb73b6c1d6364041" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", - "lazy_static", "log", "proc-macro2", "quote", @@ -2500,10 +4290,23 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.76" +name = "wasm-bindgen-futures" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44468aa53335841d9d6b6c023eaab07c0cd4bddbcfdee3e2bb1e8d2cb8069fef" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2511,9 +4314,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.76" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0195807922713af1e67dc66132c7328206ed9766af3858164fb583eedc25fbad" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -2524,20 +4327,73 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.76" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdb075a845574a1fa5f09fd77e43f7747599301ea3417a9fbffdeedfc1f4a29" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" -version = "0.3.53" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224b2f6b67919060055ef1a67807367c2066ed520c3862cc013d26cf893a783c" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2556,11 +4412,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.5" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "winapi", + "windows-sys 0.61.0", ] [[package]] @@ -2570,22 +4426,640 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "zerocopy" -version = "0.3.0" +name = "windows" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6580539ad917b7c026220c4b3f2c08d52ce54d6ce0dc491e66002e35388fab46" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.60.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.2.0" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d498dbd1fd7beb83c86709ae1c33ca50942889473473d287d56ce4770a18edfb" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", "syn", "synstructure", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zvariant" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 8429ba2e..63a5927a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,137 @@ [package] name = "librespot" -version = "0.3.1" +version.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +description = "An open source client library for Spotify, with support for Spotify Connect" +keywords = ["audio", "spotify", "music", "streaming", "connect"] +categories = ["multimedia::audio"] +repository.workspace = true +readme = "README.md" +edition.workspace = true +include = [ + "src/**/*", + "audio/**/*", + "connect/**/*", + "core/**/*", + "discovery/**/*", + "examples/**/*", + "metadata/**/*", + "oauth/**/*", + "playback/**/*", + "protocol/**/*", + "Cargo.toml", + "README.md", + "LICENSE", + "COMPILING.md", + "CONTRIBUTING.md", +] + +[workspace.package] +version = "0.7.1" +rust-version = "1.85" authors = ["Librespot Org"] license = "MIT" -description = "An open source client library for Spotify, with support for Spotify Connect" -keywords = ["spotify"] repository = "https://github.com/librespot-org/librespot" -readme = "README.md" -edition = "2018" +edition = "2024" -[workspace] +[features] +default = ["native-tls", "rodio-backend", "with-libmdns"] + +# TLS backends (mutually exclusive - compile-time checks in oauth/src/lib.rs) +# Note: Feature validation is in oauth crate since it's compiled first in the dependency tree. +# See COMPILING.md for more details on TLS backend selection. + +# native-tls: Uses the system's native TLS stack (OpenSSL on Linux, Secure Transport on macOS, +# SChannel on Windows). This is the default as it's well-tested, widely compatible, and integrates +# with system certificate stores. Choose this for maximum compatibility and when you want to use +# system-managed certificates. +native-tls = ["librespot-core/native-tls", "librespot-oauth/native-tls"] + +# rustls-tls: Uses the Rust-based rustls TLS implementation with certificate authority (CA) +# verification. This provides a Rust TLS stack (with assembly optimizations). Choose this for +# avoiding external OpenSSL dependencies, reproducible builds, or when targeting platforms where +# native TLS dependencies are unavailable or problematic (musl, embedded, static linking). +# +# Two certificate store options are available: +# +# - rustls-tls-native-roots: Uses rustls with native system certificate stores (ca-certificates on +# Linux, Security.framework on macOS, Windows certificate store on Windows). Best for most users as +# it integrates with system-managed certificates and gets security updates through the OS. +rustls-tls-native-roots = [ + "librespot-core/rustls-tls-native-roots", + "librespot-oauth/rustls-tls-native-roots", +] +# rustls-tls-webpki-roots: Uses rustls with Mozilla's compiled-in certificate store (webpki-roots). +# Best for reproducible builds, containerized environments, or when you want certificate handling +# to be independent of the host system. +rustls-tls-webpki-roots = [ + "librespot-core/rustls-tls-webpki-roots", + "librespot-oauth/rustls-tls-webpki-roots", +] + +# Audio backends - see README.md for audio backend selection guide +# Cross-platform backends: + +# rodio-backend: Cross-platform audio backend using Rodio (default). Provides good cross-platform +# compatibility with automatic backend selection. Uses ALSA on Linux, WASAPI on Windows, CoreAudio +# on macOS. +rodio-backend = ["librespot-playback/rodio-backend"] + +# rodiojack-backend: Rodio backend with JACK support for professional audio setups. +rodiojack-backend = ["librespot-playback/rodiojack-backend"] + +# gstreamer-backend: Uses GStreamer multimedia framework for audio output. +# Provides extensive audio processing capabilities. +gstreamer-backend = ["librespot-playback/gstreamer-backend"] + +# portaudio-backend: Cross-platform audio I/O library backend. +portaudio-backend = ["librespot-playback/portaudio-backend"] + +# sdl-backend: Simple DirectMedia Layer audio backend. +sdl-backend = ["librespot-playback/sdl-backend"] + +# Platform-specific backends: + +# alsa-backend: Advanced Linux Sound Architecture backend (Linux only). +# Provides low-latency audio output on Linux systems. +alsa-backend = ["librespot-playback/alsa-backend"] + +# pulseaudio-backend: PulseAudio backend (Linux only). +# Integrates with the PulseAudio sound server for advanced audio routing. +pulseaudio-backend = ["librespot-playback/pulseaudio-backend"] + +# jackaudio-backend: JACK Audio Connection Kit backend. +# Professional audio backend for low-latency, high-quality audio routing. +jackaudio-backend = ["librespot-playback/jackaudio-backend"] + +# Network discovery backends - choose one for Spotify Connect device discovery +# See COMPILING.md for dependencies and platform support. + +# with-libmdns: Pure-Rust mDNS implementation (default). +# No external dependencies, works on all platforms. Choose this for simple deployments or when +# avoiding system dependencies. +with-libmdns = ["librespot-discovery/with-libmdns"] + +# with-avahi: Uses Avahi daemon for mDNS (Linux only). +# Integrates with system's Avahi service for network discovery. Choose this when you want to +# integrate with existing Avahi infrastructure or need advanced mDNS features. Requires +# libavahi-client-dev. +with-avahi = ["librespot-discovery/with-avahi"] + +# with-dns-sd: Uses DNS Service Discovery (cross-platform). +# On macOS uses Bonjour, on Linux uses Avahi compatibility layer. Choose this for tight system +# integration on macOS or when using Avahi's dns-sd compatibility mode on Linux. +with-dns-sd = ["librespot-discovery/with-dns-sd"] + +# Audio processing features: + +# passthrough-decoder: Enables direct passthrough of Ogg Vorbis streams without decoding. +# Useful for custom audio processing pipelines or when you want to handle audio decoding +# externally. When enabled, audio is not decoded by librespot but passed through as raw Ogg Vorbis +# data. +passthrough-decoder = ["librespot-playback/passthrough-decoder"] [lib] name = "librespot" @@ -20,76 +142,71 @@ name = "librespot" path = "src/main.rs" doc = false -[dependencies.librespot-audio] -path = "audio" -version = "0.3.1" - -[dependencies.librespot-connect] -path = "connect" -version = "0.3.1" - -[dependencies.librespot-core] -path = "core" -version = "0.3.1" - -[dependencies.librespot-discovery] -path = "discovery" -version = "0.3.1" - -[dependencies.librespot-metadata] -path = "metadata" -version = "0.3.1" - -[dependencies.librespot-playback] -path = "playback" -version = "0.3.1" - -[dependencies.librespot-protocol] -path = "protocol" -version = "0.3.1" +[workspace.dependencies] +librespot-audio = { version = "0.7.1", path = "audio", default-features = false } +librespot-connect = { version = "0.7.1", path = "connect", default-features = false } +librespot-core = { version = "0.7.1", path = "core", default-features = false } +librespot-discovery = { version = "0.7.1", path = "discovery", default-features = false } +librespot-metadata = { version = "0.7.1", path = "metadata", default-features = false } +librespot-oauth = { version = "0.7.1", path = "oauth", default-features = false } +librespot-playback = { version = "0.7.1", path = "playback", default-features = false } +librespot-protocol = { version = "0.7.1", path = "protocol", default-features = false } [dependencies] -base64 = "0.13" -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" -hyper = "0.14" +librespot-audio.workspace = true +librespot-connect.workspace = true +librespot-core.workspace = true +librespot-discovery.workspace = true +librespot-metadata.workspace = true +librespot-oauth.workspace = true +librespot-playback.workspace = true +librespot-protocol.workspace = true + +data-encoding = "2.5" +env_logger = { version = "0.11.2", default-features = false, features = [ + "color", + "humantime", + "auto-color", +] } +futures-util = { version = "0.3", default-features = false } +getopts = "0.2" log = "0.4" -rpassword = "5.0" -thiserror = "1.0" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "signal", "sync", "process"] } +sha1 = "0.10" +sysinfo = { version = "0.36", default-features = false, features = ["system"] } +thiserror = "2" +tokio = { version = "1", features = [ + "rt", + "macros", + "signal", + "sync", + "process", +] } url = "2.2" -sha-1 = "0.9" - -[features] -alsa-backend = ["librespot-playback/alsa-backend"] -portaudio-backend = ["librespot-playback/portaudio-backend"] -pulseaudio-backend = ["librespot-playback/pulseaudio-backend"] -jackaudio-backend = ["librespot-playback/jackaudio-backend"] -rodio-backend = ["librespot-playback/rodio-backend"] -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"] - -default = ["rodio-backend"] [package.metadata.deb] -maintainer = "librespot-org" -copyright = "2018 Paul Liétar" +maintainer = "Librespot Organization " +copyright = "2015, Paul Liétar" license-file = ["LICENSE", "4"] depends = "$auto" +recommends = "avahi-daemon" extended-description = """\ librespot is an open source client library for Spotify. It enables applications \ -to use Spotify's service, without using the official but closed-source \ -libspotify. Additionally, it will provide extra features which are not \ -available in the official library.""" +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 closed-source libspotify. Additionally, it provides extra features \ +which are not available in the official library. +. +This package provides the librespot binary for headless Spotify Connect playback. \ +. +Note: librespot only works with Spotify Premium accounts.""" section = "sound" priority = "optional" assets = [ + # Main binary ["target/release/librespot", "usr/bin/", "755"], + # Documentation + ["README.md", "usr/share/doc/librespot/", "644"], + # Systemd services ["contrib/librespot.service", "lib/systemd/system/", "644"], - ["contrib/librespot.user.service", "lib/systemd/user/", "644"] + ["contrib/librespot.user.service", "lib/systemd/user/", "644"], ] diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 00000000..879c5b10 --- /dev/null +++ b/Cross.toml @@ -0,0 +1,12 @@ +[build] +pre-build = [ + "dpkg --add-architecture $CROSS_DEB_ARCH", + "apt-get update", + "apt-get --assume-yes install libssl-dev:$CROSS_DEB_ARCH libasound2-dev:$CROSS_DEB_ARCH", +] + +[target.riscv64gc-unknown-linux-gnu] +# RISC-V: Uses rustls-tls (no system dependencies needed) +# Building with --no-default-features --features rustls-tls +# No pre-build steps required - rustls is pure Rust +pre-build = [] diff --git a/PUBLISHING.md b/PUBLISHING.md index ceab506c..3859c90a 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -2,15 +2,30 @@ ## How To -The bash script in the root of the project, named `publish.sh` can be used to publish a new version of librespot and it's corresponding crates. the command should be used as follows: `./publish 0.1.0` from the project root, substituting the new version number that you wish to publish. *Note the lack of a v prefix on the version number. This is important, do not add one.* The v prefix is added where appropriate by the script. +Read through this paragraph in its entirety before running anything. + +The Bash script in the root of the project, named `publish.sh` can be used to publish a new version of librespot and its corresponding crates. the command should be used as follows from the project root: `./publish 0.1.0` from the project root, substituting the new version number that you wish to publish. *Note the lack of a v prefix on the version number. This is important, do not add one.* The v prefix is added where appropriate by the script. + +Make sure that you are are starting from a clean working directory for both `dev` and `master`, completely up to date with remote and all local changes either committed and pushed or stashed. + +Note that the script will update the crates and lockfile, so in case you did not do so before, you really should to make sure none of the dependencies introduce some SemVer breaking change. Then commit so you again have a clean working directory. + +Also don't forget to update `CHANGELOG.md` with the version number, release date, and at the bottom the comparison links. + +You will want to perform a dry run first: `./publish --dry-run 0.1.0`. Please make note of any errors or warnings. In particular, you may need to explicitly inform Git which remote you want to track for the `master` branch like so: `git --track origin/master` (or whatever you have called the `librespot-org` remote `master` branch). + +Depending on your system the script may fail to publish the main `librespot` crate after having published all the `librespot-xyz` sub-crates. If so then make sure the working directory is committed and pushed (watch `Cargo.toml`) and then run `cargo publish` manually after `publish.sh` finished. + +To publish the crates your GitHub account needs to be authorized on `crates.io` by `librespot-org`. First time you should run `cargo login` and follow the on-screen instructions. ## What the script does This is briefly how the script works: - Change to branch master, pull latest version, merge development branch. - - CD to working dir. + - Change to working directory. - Change version number in all files. + - Update crates and lockfile. - Commit and tag changes. - Publish crates in given order. - Push version commit and tags to master. @@ -25,4 +40,4 @@ The `protocol` package needs to be published with `cargo publish --no-verify` du Publishing can be done using the command `cargo publish` in each of the directories of the respective crate. -The script is meant to cover the standard publishing process. There are various improvements that could be made, such as adding options such as the user being able to add a change log, though this is not the main focus, as the script is intended to be run by a CI. Feel free to improve and extend functionality, keeping in mind that it should always be possible for the script to be run in a non-interactive fashion. +The script is meant to cover the standard publishing process. There are various improvements that could be made, such as adding options such as the user being able to add a changelog, though this is not the main focus, as the script is intended to be run by a CI. Feel free to improve and extend functionality, keeping in mind that it should always be possible for the script to be run in a non-interactive fashion. diff --git a/README.md b/README.md index 20afc01b..caa00ea0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://github.com/librespot-org/librespot/workflows/test/badge.svg)](https://github.com/librespot-org/librespot/actions) +[![Build Status](https://github.com/librespot-org/librespot/workflows/build/badge.svg)](https://github.com/librespot-org/librespot/actions) [![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) @@ -62,13 +62,15 @@ SDL Pipe Subprocess ``` -Please check the corresponding [Compiling](https://github.com/librespot-org/librespot/wiki/Compiling#general-dependencies) entry on the wiki for backend specific dependencies. +Please check [COMPILING.md](COMPILING.md) for detailed information on TLS, audio, and discovery backend dependencies, or the [Compiling](https://github.com/librespot-org/librespot/wiki/Compiling#general-dependencies) entry on the wiki for additional backend specific dependencies. -Once you've installed the dependencies and cloned this repository you can build *librespot* with the default backend using Cargo. +Once you've installed the dependencies and cloned this repository you can build *librespot* with the default features using Cargo. ```shell cargo build --release ``` +By default, this builds with native-tls (system TLS), rodio audio backend, and libmdns discovery. See [COMPILING.md](COMPILING.md) for information on selecting different TLS, audio, and discovery backends. + # Packages librespot is also available via official package system on various operating systems such as Linux, FreeBSD, NetBSD. [Repology](https://repology.org/project/librespot/versions) offers a good overview. @@ -108,11 +110,13 @@ This is a non exhaustive list of projects that either use or have modified libre - [librespot-golang](https://github.com/librespot-org/librespot-golang) - A golang port of librespot. - [plugin.audio.spotify](https://github.com/marcelveldt/plugin.audio.spotify) - A Kodi plugin for Spotify. -- [raspotify](https://github.com/dtcooper/raspotify) - Spotify Connect client for the Raspberry Pi that Just Works™ +- [raspotify](https://github.com/dtcooper/raspotify) - A Spotify Connect client that mostly Just Works™ - [Spotifyd](https://github.com/Spotifyd/spotifyd) - A stripped down librespot UNIX daemon. - [rpi-audio-receiver](https://github.com/nicokaiser/rpi-audio-receiver) - easy Raspbian install scripts for Spotifyd, Bluetooth, Shairport and other audio receivers -- [Spotcontrol](https://github.com/badfortrains/spotcontrol) - A golang implementation of a Spotify Connect controller. No playback -functionality. +- [Spotcontrol](https://github.com/badfortrains/spotcontrol) - A golang implementation of a Spotify Connect controller. No Playback 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. +- [Snapcast](https://github.com/badaix/snapcast) - synchronised multi-room audio player that uses librespot as its source for Spotify content +- [MuPiBox](https://mupibox.de/) - Portable music box for Spotify and local media based on Raspberry Pi. Operated via touchscreen. Suitable for children and older people. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..6a1c6b2e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Supported Versions + +We will support the latest release and main development branch with security updates. + +## Reporting a Vulnerability + +If you believe to have found a vulnerability in `librespot` itself or as a result from +one of its dependencies, please report it by contacting one or more of the active +maintainers directly, allowing no less than three calendar days to receive a response. + +If you believe that the vulnerability is public knowledge or already being exploited +in the wild, regardless of having received a response to your direct messages or not, +please create an issue report to warn other users about continued use and instruct +them on any known workarounds. + +On your report you may expect feedback on whether we believe that the vulnerability +is indeed applicable and if so, when and how it may be fixed. You may expect to +be asked for assistance with review and testing. diff --git a/audio/Cargo.toml b/audio/Cargo.toml index 77855e62..3ff3aac1 100644 --- a/audio/Cargo.toml +++ b/audio/Cargo.toml @@ -1,20 +1,33 @@ [package] name = "librespot-audio" -version = "0.3.1" +version.workspace = true +rust-version.workspace = true authors = ["Paul Lietar "] -description="The audio fetching and processing logic for librespot" -license="MIT" -edition = "2018" +license.workspace = true +description = "The audio fetching logic for librespot" +repository.workspace = true +edition.workspace = true -[dependencies.librespot-core] -path = "../core" -version = "0.3.1" +[features] +# Refer to the workspace Cargo.toml for the list of features +default = ["native-tls"] + +# TLS backend propagation +native-tls = ["librespot-core/native-tls"] +rustls-tls-native-roots = ["librespot-core/rustls-tls-native-roots"] +rustls-tls-webpki-roots = ["librespot-core/rustls-tls-webpki-roots"] [dependencies] -aes-ctr = "0.6" -byteorder = "1.4" -bytes = "1.0" +librespot-core = { version = "0.7.1", path = "../core", default-features = false } + +aes = "0.8" +bytes = "1" +ctr = "0.9" +futures-util = { version = "0.3", default-features = false, features = ["std"] } +http-body-util = "0.1" +hyper = { version = "1.6", features = ["http1", "http2"] } +hyper-util = { version = "0.1", features = ["client", "http2"] } log = "0.4" -futures-util = { version = "0.3", default_features = false } -tempfile = "3.1" -tokio = { version = "1", features = ["sync", "macros"] } +tempfile = "3" +thiserror = "2" +tokio = { version = "1", features = ["macros", "sync"] } diff --git a/audio/src/decrypt.rs b/audio/src/decrypt.rs index 17f4edba..365ec46e 100644 --- a/audio/src/decrypt.rs +++ b/audio/src/decrypt.rs @@ -1,8 +1,8 @@ use std::io; -use aes_ctr::cipher::generic_array::GenericArray; -use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher, SyncStreamCipherSeek}; -use aes_ctr::Aes128Ctr; +use aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; + +type Aes128Ctr = ctr::Ctr128BE; use librespot_core::audio_key::AudioKey; @@ -11,16 +11,20 @@ const AUDIO_AESIV: [u8; 16] = [ ]; pub struct AudioDecrypt { - cipher: Aes128Ctr, + // a `None` cipher is a convenience to make `AudioDecrypt` pass files unaltered + cipher: Option, reader: T, } impl AudioDecrypt { - pub fn new(key: AudioKey, reader: T) -> AudioDecrypt { - let cipher = Aes128Ctr::new( - GenericArray::from_slice(&key.0), - GenericArray::from_slice(&AUDIO_AESIV), - ); + pub fn new(key: Option, reader: T) -> AudioDecrypt { + let cipher = if let Some(key) = key { + Aes128Ctr::new_from_slices(&key.0, &AUDIO_AESIV).ok() + } else { + // some files are unencrypted + None + }; + AudioDecrypt { cipher, reader } } } @@ -29,7 +33,9 @@ impl io::Read for AudioDecrypt { fn read(&mut self, output: &mut [u8]) -> io::Result { let len = self.reader.read(output)?; - self.cipher.apply_keystream(&mut output[..len]); + if let Some(ref mut cipher) = self.cipher { + cipher.apply_keystream(&mut output[..len]); + } Ok(len) } @@ -39,7 +45,9 @@ impl io::Seek for AudioDecrypt { fn seek(&mut self, pos: io::SeekFrom) -> io::Result { let newpos = self.reader.seek(pos)?; - self.cipher.seek(newpos); + if let Some(ref mut cipher) = self.cipher { + cipher.seek(newpos); + } Ok(newpos) } diff --git a/audio/src/fetch/mod.rs b/audio/src/fetch/mod.rs index 636194a8..6a6379b9 100644 --- a/audio/src/fetch/mod.rs +++ b/audio/src/fetch/mod.rs @@ -1,87 +1,133 @@ 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::min, + fs, + io::{self, Read, Seek, SeekFrom}, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + sync::{Condvar, Mutex}, + time::Duration, +}; + +use futures_util::{StreamExt, TryFutureExt, future::IntoStream}; +use hyper::{Response, StatusCode, body::Incoming, header::CONTENT_RANGE}; +use hyper_util::client::legacy::ResponseFuture; -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 tempfile::NamedTempFile; -use tokio::sync::{mpsc, oneshot}; +use thiserror::Error; +use tokio::sync::{Semaphore, mpsc, oneshot}; + +use librespot_core::{Error, FileId, Session, cdn_url::CdnUrl}; + +use self::receive::audio_file_fetch; -use self::receive::{audio_file_fetch, request_range}; use crate::range_set::{Range, RangeSet}; -/// 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 type AudioFileResult = Result<(), librespot_core::Error>; -/// 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; +const DOWNLOAD_STATUS_POISON_MSG: &str = "audio download status mutex should not be poisoned"; -/// 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); +#[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, +} -/// 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); +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), + } + } +} -/// 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 -/// of audio data may be larger or smaller. -pub const READ_AHEAD_BEFORE_PLAYBACK: Duration = Duration::from_secs(1); +#[derive(Clone)] +pub struct AudioFetchParams { + /// The minimum size of a block that is requested from the Spotify servers in one request. + /// This is the block size that is typically requested while doing a `seek()` on a file. + /// The Symphonia decoder requires this to be a power of 2 and > 32 kB. + /// Note: smaller requests can happen if part of the block is downloaded already. + pub minimum_download_size: usize, -/// Same as `READ_AHEAD_BEFORE_PLAYBACK`, but the time is taken as a factor of the ping -/// time to the Spotify server. Both `READ_AHEAD_BEFORE_PLAYBACK` and -/// `READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS` are obeyed. -/// Note: the calculations are done using the nominal bitrate of the file. The actual amount -/// of audio data may be larger or smaller. -pub const READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS: f32 = 2.0; + /// The minimum network throughput that we expect. Together with the minimum download size, + /// this will determine the time we will wait for a response. + pub minimum_throughput: usize, -/// While playing back, this many seconds of data ahead of the current read position are -/// requested. -/// Note: the calculations are done using the nominal bitrate of the file. The actual amount -/// of audio data may be larger or smaller. -pub const READ_AHEAD_DURING_PLAYBACK: Duration = Duration::from_secs(5); + /// The ping time that is used for calculations before a ping time was actually measured. + pub initial_ping_time_estimate: Duration, -/// Same as `READ_AHEAD_DURING_PLAYBACK`, but the time is taken as a factor of the ping -/// time to the Spotify server. -/// Note: the calculations are done using the nominal bitrate of the file. The actual amount -/// of audio data may be larger or smaller. -pub const READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS: f32 = 10.0; + /// 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. + pub maximum_assumed_ping_time: Duration, -/// 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; + /// 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 + /// of audio data may be larger or smaller. + pub read_ahead_before_playback: Duration, -/// Similar to `PREFETCH_THRESHOLD_FACTOR`, but it also takes the current download rate into account. -/// The formula used is ` < FAST_PREFETCH_THRESHOLD_FACTOR * * ` -/// This mechanism allows for fast downloading of the remainder of the file. The number should be larger -/// than `1.0` so the download rate ramps up until the bandwidth is saturated. The larger the value, the faster -/// 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; + /// While playing back, this many seconds of data ahead of the current read position are + /// requested. + /// Note: the calculations are done using the nominal bitrate of the file. The actual amount + /// of audio data may be larger or smaller. + pub read_ahead_during_playback: Duration, -/// 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 -/// 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; + /// 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 * * ` + pub prefetch_threshold_factor: f32, -/// The time we will wait to obtain status updates on downloading. -const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(1); + /// The time we will wait to obtain status updates on downloading. + pub download_timeout: Duration, +} + +impl Default for AudioFetchParams { + fn default() -> Self { + let minimum_download_size = 64 * 1024; + let minimum_throughput = 8 * 1024; + Self { + minimum_download_size, + minimum_throughput, + initial_ping_time_estimate: Duration::from_millis(500), + maximum_assumed_ping_time: Duration::from_millis(1500), + read_ahead_before_playback: Duration::from_secs(1), + read_ahead_during_playback: Duration::from_secs(5), + prefetch_threshold_factor: 4.0, + download_timeout: Duration::from_secs( + (minimum_download_size / minimum_throughput) as u64, + ), + } + } +} + +static AUDIO_FETCH_PARAMS: OnceLock = OnceLock::new(); + +impl AudioFetchParams { + pub fn set(params: AudioFetchParams) -> Result<(), AudioFetchParams> { + AUDIO_FETCH_PARAMS.set(params) + } + + pub fn get() -> &'static AudioFetchParams { + AUDIO_FETCH_PARAMS.get_or_init(AudioFetchParams::default) + } +} pub enum AudioFile { Cached(fs::File), @@ -89,11 +135,17 @@ pub enum AudioFile { } #[derive(Debug)] -enum StreamLoaderCommand { - Fetch(Range), // signal the stream loader to fetch a range of the file - RandomAccessMode(), // optimise download strategy for random access - StreamMode(), // optimise download strategy for streaming - Close(), // terminate and don't load any more data +pub struct StreamingRequest { + streamer: IntoStream, + initial_response: Option>, + offset: usize, + length: usize, +} + +#[derive(Debug)] +pub enum StreamLoaderCommand { + Fetch(Range), // signal the stream loader to fetch a range of the file + Close, // terminate and don't load any more data } #[derive(Clone)] @@ -114,7 +166,11 @@ 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 download_status = shared + .download_status + .lock() + .expect(DOWNLOAD_STATUS_POISON_MSG); + range.length <= download_status .downloaded @@ -125,21 +181,23 @@ impl StreamLoaderController { } 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(); + self.range_available(Range::new(read_position, self.len() - read_position)) + } + None => true, + } } - pub fn ping_time(&self) -> Duration { - Duration::from_millis(self.stream_shared.as_ref().map_or(0, |shared| { - shared.ping_time_ms.load(atomic::Ordering::Relaxed) as u64 - })) + pub fn ping_time(&self) -> Option { + self.stream_shared.as_ref().map(|shared| shared.ping_time()) } 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 +207,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 +220,27 @@ 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() + .expect(DOWNLOAD_STATUS_POISON_MSG); + let download_timeout = AudioFetchParams::get().download_timeout; + while range.length > download_status .downloaded .contained_length_from_value(range.start) { - download_status = shared + let (new_download_status, wait_result) = shared .cond - .wait_timeout(download_status, DOWNLOAD_TIMEOUT) - .unwrap() - .0; + .wait_timeout(download_status, download_timeout) + .expect(DOWNLOAD_STATUS_POISON_MSG); + + download_status = new_download_status; + if wait_result.timed_out() { + return Err(AudioFileError::WaitTimeout.into()); + } + if range.length > (download_status .downloaded @@ -185,41 +253,52 @@ impl StreamLoaderController { } } } + + Ok(()) } - pub fn fetch_next(&self, length: usize) { - if let Some(ref shared) = self.stream_shared { - let range = Range { - start: shared.read_position.load(atomic::Ordering::Relaxed), - length, - }; - self.fetch(range) - } - } + pub fn fetch_next_and_wait( + &self, + request_length: usize, + wait_length: usize, + ) -> AudioFileResult { + match self.stream_shared { + Some(ref shared) => { + let start = shared.read_position(); - 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); + let request_range = Range { + start, + length: request_length, + }; + self.fetch(request_range); + + let wait_range = Range { + start, + length: wait_length, + }; + self.fetch_blocking(wait_range) + } + None => Ok(()), } } pub fn set_random_access_mode(&self) { // optimise download strategy for random access - self.send_stream_loader_command(StreamLoaderCommand::RandomAccessMode()); + if let Some(ref shared) = self.stream_shared { + shared.set_download_streaming(false) + } } pub fn set_stream_mode(&self) { // optimise download strategy for streaming - self.send_stream_loader_command(StreamLoaderCommand::StreamMode()); + if let Some(ref shared) = self.stream_shared { + shared.set_download_streaming(true) + } } pub fn close(&self) { // terminate stream loading and don't load any more data for this file. - self.send_stream_loader_command(StreamLoaderCommand::Close()); + self.send_stream_loader_command(StreamLoaderCommand::Close); } } @@ -235,22 +314,58 @@ struct AudioFileDownloadStatus { downloaded: RangeSet, } -#[derive(Copy, Clone, PartialEq, Eq)] -enum DownloadStrategy { - RandomAccess(), - Streaming(), -} - struct AudioFileShared { - file_id: FileId, + cdn_url: String, file_size: usize, - stream_data_rate: usize, + bytes_per_second: usize, cond: Condvar, download_status: Mutex, - download_strategy: Mutex, - number_of_open_requests: AtomicUsize, + download_streaming: AtomicBool, + download_slots: Semaphore, ping_time_ms: AtomicUsize, read_position: AtomicUsize, + throughput: AtomicUsize, +} + +impl AudioFileShared { + fn is_download_streaming(&self) -> bool { + self.download_streaming.load(Ordering::Acquire) + } + + fn set_download_streaming(&self, streaming: bool) { + self.download_streaming.store(streaming, Ordering::Release) + } + + fn ping_time(&self) -> Duration { + let ping_time_ms = self.ping_time_ms.load(Ordering::Acquire); + if ping_time_ms > 0 { + Duration::from_millis(ping_time_ms as u64) + } else { + AudioFetchParams::get().initial_ping_time_estimate + } + } + + fn set_ping_time(&self, duration: Duration) { + self.ping_time_ms + .store(duration.as_millis() as usize, Ordering::Release) + } + + fn throughput(&self) -> usize { + self.throughput.load(Ordering::Acquire) + } + + fn set_throughput(&self, throughput: usize) { + self.throughput.store(throughput, Ordering::Release) + } + + fn read_position(&self) -> usize { + self.read_position.load(Ordering::Acquire) + } + + fn set_read_position(&self, position: u64) { + self.read_position + .store(position as usize, Ordering::Release) + } } impl AudioFile { @@ -258,69 +373,52 @@ impl AudioFile { session: &Session, file_id: FileId, bytes_per_second: usize, - play_from_beginning: bool, - ) -> Result { + ) -> Result { if let Some(file) = session.cache().and_then(|cache| cache.file(file_id)) { - debug!("File {} already in cache", file_id); + debug!("File {file_id} already in cache"); return Ok(AudioFile::Cached(file)); } - debug!("Downloading file {}", file_id); + debug!("Downloading file {file_id}"); let (complete_tx, complete_rx) = oneshot::channel(); - let mut initial_data_length = if play_from_beginning { - INITIAL_DOWNLOAD_SIZE - + max( - (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, - (INITIAL_PING_TIME_ESTIMATE.as_secs_f32() - * READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS - * bytes_per_second as f32) as usize, - ) - } else { - INITIAL_DOWNLOAD_SIZE - }; - if initial_data_length % 4 != 0 { - initial_data_length += 4 - (initial_data_length % 4); - } - let (headers, data) = request_range(session, file_id, 0, initial_data_length).split(); - let streaming = AudioFileStreaming::open( - session.clone(), - data, - initial_data_length, - Instant::now(), - headers, - file_id, - complete_tx, - bytes_per_second, - ); + let streaming = + AudioFileStreaming::open(session.clone(), file_id, complete_tx, bytes_per_second); let session_ = session.clone(); session.spawn(complete_rx.map_ok(move |mut file| { + debug!("Downloading file {file_id} complete"); + if let Some(cache) = session_.cache() { - debug!("File {} complete, saving to cache", file_id); - cache.save_file(file_id, &mut file); - } else { - debug!("File {} complete", file_id); + if let Some(cache_id) = cache.file_path(file_id) { + if let Err(e) = cache.save_file(file_id, &mut file) { + error!("Error caching file {file_id} to {cache_id:?}: {e}"); + } else { + debug!("File {file_id} cached to {cache_id:?}"); + } + } } })); Ok(AudioFile::Streaming(streaming.await?)) } - pub fn get_stream_loader_controller(&self) -> StreamLoaderController { - match self { - AudioFile::Streaming(ref stream) => StreamLoaderController { + pub fn get_stream_loader_controller(&self) -> Result { + let controller = match self { + AudioFile::Streaming(stream) => StreamLoaderController { channel_tx: Some(stream.stream_loader_command_tx.clone()), stream_shared: Some(stream.shared.clone()), file_size: stream.shared.file_size, }, - AudioFile::Cached(ref file) => StreamLoaderController { + AudioFile::Cached(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 +429,104 @@ impl AudioFile { impl AudioFileStreaming { pub async fn open( session: Session, - initial_data_rx: ChannelData, - initial_data_length: usize, - initial_request_sent_time: Instant, - headers: ChannelHeaders, file_id: FileId, complete_tx: oneshot::Sender, - streaming_data_rate: usize, - ) -> Result { - let (_, data) = headers - .try_filter(|(id, _)| future::ready(*id == 0x3)) - .next() - .await - .unwrap()?; + bytes_per_second: usize, + ) -> Result { + let cdn_url = CdnUrl::new(file_id).resolve_audio(&session).await?; - let size = BigEndian::read_u32(&data) as usize * 4; + let minimum_download_size = AudioFetchParams::get().minimum_download_size; + + let mut response_streamer_url = None; + let urls = cdn_url.try_get_urls()?; + for url in &urls { + // When the audio file is really small, this `download_size` may turn out to be + // larger than the audio file we're going to stream later on. This is OK; requesting + // `Content-Range` > `Content-Length` will return the complete file with status code + // 206 Partial Content. + let mut streamer = + session + .spclient() + .stream_from_cdn(*url, 0, minimum_download_size)?; + + // Get the first chunk with the headers to get the file size. + // The remainder of that chunk with possibly also a response body is then + // further processed in `audio_file_fetch`. + let streamer_result = tokio::time::timeout(Duration::from_secs(10), streamer.next()) + .await + .map_err(|_| AudioFileError::WaitTimeout.into()) + .and_then(|x| x.ok_or_else(|| AudioFileError::NoData.into())) + .and_then(|x| x.map_err(Error::from)); + + match streamer_result { + Ok(r) => { + response_streamer_url = Some((r, streamer, url)); + break; + } + Err(e) => warn!("Fetching {url} failed with error {e:?}, trying next"), + } + } + + let Some((response, streamer, url)) = response_streamer_url else { + return Err(Error::unavailable(format!( + "{} URLs failed, none left to try", + urls.len() + ))); + }; + + trace!("Streaming from {url}"); + + let code = response.status(); + if code != StatusCode::PARTIAL_CONTENT { + debug!("Opening audio file expected partial content but got: {code}"); + return Err(AudioFileError::StatusCode(code).into()); + } + + let header_value = response + .headers() + .get(CONTENT_RANGE) + .ok_or(AudioFileError::Header)?; + let str_value = header_value.to_str()?; + let hyphen_index = str_value.find('-').unwrap_or_default(); + let slash_index = str_value.find('/').unwrap_or_default(); + let upper_bound: usize = str_value[hyphen_index + 1..slash_index].parse()?; + let file_size = str_value[slash_index + 1..].parse()?; + + let initial_request = StreamingRequest { + streamer, + initial_response: Some(response), + offset: 0, + length: upper_bound + 1, + }; let shared = Arc::new(AudioFileShared { - file_id, - file_size: size, - stream_data_rate: streaming_data_rate, + cdn_url: url.to_string(), + 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 - number_of_open_requests: AtomicUsize::new(0), + download_streaming: AtomicBool::new(false), + download_slots: Semaphore::new(1), ping_time_ms: AtomicUsize::new(0), read_position: AtomicUsize::new(0), + throughput: AtomicUsize::new(0), }); - let mut write_file = NamedTempFile::new().unwrap(); - write_file.as_file().set_len(size as u64).unwrap(); - write_file.seek(SeekFrom::Start(0)).unwrap(); + let write_file = NamedTempFile::new_in(session.config().tmp_dir.clone())?; + write_file.as_file().set_len(file_size as u64)?; - let read_file = write_file.reopen().unwrap(); + let read_file = write_file.reopen()?; - // let (seek_tx, seek_rx) = mpsc::unbounded(); let (stream_loader_command_tx, stream_loader_command_rx) = mpsc::unbounded_channel::(); session.spawn(audio_file_fetch( session.clone(), shared.clone(), - initial_data_rx, - initial_request_sent_time, - initial_data_length, + initial_request, write_file, stream_loader_command_rx, complete_tx, @@ -401,83 +550,68 @@ impl Read for AudioFileStreaming { } let length = min(output.len(), self.shared.file_size - offset); + if length == 0 { + return Ok(0); + } - let length_to_request = match *(self.shared.download_strategy.lock().unwrap()) { - DownloadStrategy::RandomAccess() => length, - DownloadStrategy::Streaming() => { - // Due to the read-ahead stuff, we potentially request more than the actual request demanded. - let ping_time_seconds = Duration::from_millis( - self.shared.ping_time_ms.load(atomic::Ordering::Relaxed) as u64, - ) - .as_secs_f32(); + let read_ahead_during_playback = AudioFetchParams::get().read_ahead_during_playback; + let length_to_request = if self.shared.is_download_streaming() { + let length_to_request = length + + (read_ahead_during_playback.as_secs_f32() * self.shared.bytes_per_second as f32) + as usize; - let length_to_request = length - + max( - (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() - * self.shared.stream_data_rate as f32) as usize, - (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS - * ping_time_seconds - * self.shared.stream_data_rate as f32) as usize, - ); - min(length_to_request, self.shared.file_size - offset) - } + // Due to the read-ahead stuff, we potentially request more than the actual request demanded. + min(length_to_request, self.shared.file_size - offset) + } else { + length }; let mut ranges_to_request = RangeSet::new(); ranges_to_request.add_range(&Range::new(offset, length_to_request)); - let mut download_status = self.shared.download_status.lock().unwrap(); + let mut download_status = self + .shared + .download_status + .lock() + .expect(DOWNLOAD_STATUS_POISON_MSG); + 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; + let download_timeout = AudioFetchParams::get().download_timeout; 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 + let (new_download_status, wait_result) = self .shared .cond - .wait_timeout(download_status, DOWNLOAD_TIMEOUT) - .unwrap() - .0; + .wait_timeout(download_status, download_timeout) + .expect(DOWNLOAD_STATUS_POISON_MSG); + + download_status = new_download_status; + if wait_result.timed_out() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + Error::deadline_exceeded(AudioFileError::WaitTimeout), + )); + } } let available_length = download_status .downloaded .contained_length_from_value(offset); - assert!(available_length > 0); + drop(download_status); - self.position = self.read_file.seek(SeekFrom::Start(offset as u64)).unwrap(); + self.position = self.read_file.seek(SeekFrom::Start(offset as u64))?; let read_len = min(length, available_length); let read_len = self.read_file.read(&mut output[..read_len])?; - if download_message_printed { - debug!( - "Read at postion {} completed. {} bytes returned, {} bytes were requested.", - offset, - read_len, - output.len() - ); - } - self.position += read_len as u64; - self.shared - .read_position - .store(self.position as usize, atomic::Ordering::Relaxed); + self.shared.set_read_position(self.position); Ok(read_len) } @@ -485,11 +619,45 @@ impl Read for AudioFileStreaming { impl Seek for AudioFileStreaming { fn seek(&mut self, pos: SeekFrom) -> io::Result { + // If we are already at this position, we don't need to switch download mode. + // These checks and locks are less expensive than interrupting streaming. + let current_position = self.position as i64; + let requested_pos = match pos { + SeekFrom::Start(pos) => pos as i64, + SeekFrom::End(pos) => self.shared.file_size as i64 - pos - 1, + SeekFrom::Current(pos) => current_position + pos, + }; + if requested_pos == current_position { + return Ok(current_position as u64); + } + + // Again if we have already downloaded this part. + let available = self + .shared + .download_status + .lock() + .expect(DOWNLOAD_STATUS_POISON_MSG) + .downloaded + .contains(requested_pos as usize); + + let mut was_streaming = false; + if !available { + // Ensure random access mode if we need to download this part. + // Checking whether we are streaming now is a micro-optimization + // to save an atomic load. + was_streaming = self.shared.is_download_streaming(); + if was_streaming { + self.shared.set_download_streaming(false); + } + } + self.position = self.read_file.seek(pos)?; - // Do not seek past EOF - self.shared - .read_position - .store(self.position as usize, atomic::Ordering::Relaxed); + self.shared.set_read_position(self.position); + + if !available && was_streaming { + self.shared.set_download_streaming(true); + } + Ok(self.position) } } diff --git a/audio/src/fetch/receive.rs b/audio/src/fetch/receive.rs index f7574f4f..4c894cf6 100644 --- a/audio/src/fetch/receive.rs +++ b/audio/src/fetch/receive.rs @@ -1,143 +1,150 @@ -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::Arc, + time::{Duration, Instant}, +}; -use atomic::Ordering; -use byteorder::{BigEndian, WriteBytesExt}; use bytes::Bytes; use futures_util::StreamExt; -use librespot_core::channel::{Channel, ChannelData}; -use librespot_core::session::Session; -use librespot_core::spotify_id::FileId; +use http_body_util::BodyExt; +use hyper::StatusCode; use tempfile::NamedTempFile; use tokio::sync::{mpsc, oneshot}; +use librespot_core::{Error, http_client::HttpClient, session::Session}; + 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, + AudioFetchParams, AudioFileError, AudioFileResult, AudioFileShared, StreamLoaderCommand, + StreamingRequest, }; -pub fn request_range(session: &Session, file: FileId, offset: usize, length: usize) -> Channel { - assert!( - offset % 4 == 0, - "Range request start positions must be aligned by 4 bytes." - ); - assert!( - length % 4 == 0, - "Range request range lengths must be aligned by 4 bytes." - ); - let start = offset / 4; - let end = (offset + length) / 4; - - let (id, channel) = session.channel().allocate(); - - let mut data: Vec = Vec::new(); - data.write_u16::(id).unwrap(); - data.write_u8(0).unwrap(); - data.write_u8(1).unwrap(); - data.write_u16::(0x0000).unwrap(); - data.write_u32::(0x00000000).unwrap(); - data.write_u32::(0x00009C40).unwrap(); - data.write_u32::(0x00020000).unwrap(); - data.write(&file.0).unwrap(); - data.write_u32::(start as u32).unwrap(); - data.write_u32::(end as u32).unwrap(); - - session.send_packet(0x8, data); - - channel -} - struct PartialFileData { offset: usize, data: Bytes, } enum ReceivedData { + Throughput(usize), ResponseTime(Duration), Data(PartialFileData), } +const ONE_SECOND: Duration = Duration::from_secs(1); +const DOWNLOAD_STATUS_POISON_MSG: &str = "audio download status mutex should not be poisoned"; + async fn receive_data( shared: Arc, file_data_tx: mpsc::UnboundedSender, - mut data_rx: ChannelData, - initial_data_offset: usize, - initial_request_length: usize, - request_sent_time: Instant, -) { - let mut data_offset = initial_data_offset; - let mut request_length = initial_request_length; + mut request: StreamingRequest, +) -> AudioFileResult { + let mut offset = request.offset; + let mut actual_length = 0; - let old_number_of_request = shared - .number_of_open_requests - .fetch_add(1, Ordering::SeqCst); + let permit = shared.download_slots.acquire().await?; - let mut measure_ping_time = old_number_of_request == 0; + let request_time = Instant::now(); + let mut measure_ping_time = true; + let mut measure_throughput = true; - 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) => { + // the request was already made outside of this function + measure_ping_time = false; + measure_throughput = false; + + data + } + None => match request.streamer.next().await { + Some(Ok(response)) => response, + Some(Err(e)) => break Err(e.into()), + None => { + if actual_length != request.length { + let msg = format!("did not expect body to contain {actual_length} bytes"); + break Err(Error::data_loss(msg)); + } + + break Ok(()); + } + }, }; if measure_ping_time { - let mut duration = Instant::now() - request_sent_time; - if duration > MAXIMUM_ASSUMED_PING_TIME { - duration = MAXIMUM_ASSUMED_PING_TIME; + let duration = Instant::now().duration_since(request_time); + // may be zero if we are handling an initial response + if duration.as_millis() > 0 { + file_data_tx.send(ReceivedData::ResponseTime(duration))?; + measure_ping_time = false; } - let _ = file_data_tx.send(ReceivedData::ResponseTime(duration)); - measure_ping_time = false; - } - let data_size = data.len(); - let _ = file_data_tx.send(ReceivedData::Data(PartialFileData { - offset: data_offset, - data, - })); - data_offset += data_size; - if request_length < data_size { - warn!( - "Data receiver for range {} (+{}) received more data from server than requested.", - initial_data_offset, initial_request_length - ); - request_length = 0; - } else { - request_length -= data_size; } - if request_length == 0 { - break Ok(()); + let code = response.status(); + if code != StatusCode::PARTIAL_CONTENT { + if code == StatusCode::TOO_MANY_REQUESTS { + if let Some(duration) = HttpClient::get_retry_after(response.headers()) { + warn!( + "Rate limiting, retrying in {} seconds...", + duration.as_secs() + ); + // sleeping here means we hold onto this streamer "slot" + // (we don't decrease the number of open requests) + tokio::time::sleep(duration).await; + } + } + + break Err(AudioFileError::StatusCode(code).into()); } + + let body = response.into_body(); + let data = match body.collect().await.map(|b| b.to_bytes()) { + Ok(bytes) => bytes, + Err(e) => break Err(e.into()), + }; + + let data_size = data.len(); + file_data_tx.send(ReceivedData::Data(PartialFileData { offset, data }))?; + + actual_length += data_size; + offset += data_size; }; - if 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 measure_throughput { + let duration = Instant::now().duration_since(request_time).as_millis(); + if actual_length > 0 && duration > 0 { + let throughput = ONE_SECOND.as_millis() as usize * actual_length / duration as usize; + file_data_tx.send(ReceivedData::Throughput(throughput))?; + } } - 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 - ); + let bytes_remaining = request.length - actual_length; + if bytes_remaining > 0 { + { + let missing_range = Range::new(offset, bytes_remaining); + let mut download_status = shared + .download_status + .lock() + .expect(DOWNLOAD_STATUS_POISON_MSG); + download_status.requested.subtract_range(&missing_range); + shared.cond.notify_all(); + } } + + drop(permit); + + if let Err(e) = result { + error!( + "Streamer error requesting range {} +{}: {:?}", + request.offset, request.length, e + ); + return Err(e); + } + + Ok(()) } struct AudioFileFetch { @@ -148,6 +155,8 @@ struct AudioFileFetch { file_data_tx: mpsc::UnboundedSender, complete_tx: Option>, network_response_times: Vec, + + params: AudioFetchParams, } // Might be replaced by enum from std once stable @@ -158,116 +167,150 @@ enum ControlFlow { } impl AudioFileFetch { - fn get_download_strategy(&mut self) -> DownloadStrategy { - *(self.shared.download_strategy.lock().unwrap()) + fn has_download_slots_available(&self) -> bool { + self.shared.download_slots.available_permits() > 0 } - fn download_range(&mut self, mut offset: usize, mut length: usize) { - if length < MINIMUM_DOWNLOAD_SIZE { - length = MINIMUM_DOWNLOAD_SIZE; + fn download_range(&mut self, offset: usize, mut length: usize) -> AudioFileResult { + if length < self.params.minimum_download_size { + length = self.params.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 we are in streaming mode (so not seeking) then start downloading as large + // of chunks as possible for better throughput and improved CPU usage, while + // still being reasonably responsive (~1 second) in case we want to seek. + if self.shared.is_download_streaming() { + let throughput = self.shared.throughput(); + length = max(length, throughput); } 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() + .expect(DOWNLOAD_STATUS_POISON_MSG); 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, + }; + 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) { - let mut bytes_to_go = bytes; - let mut requests_to_go = max_requests_to_send; - - while bytes_to_go > 0 && requests_to_go > 0 { - // determine what is still missing - 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(); - missing_data.subtract_range_set(&download_status.downloaded); - missing_data.subtract_range_set(&download_status.requested); - } - - // download data from after the current read position first - let mut tail_end = RangeSet::new(); - let read_position = self.shared.read_position.load(Ordering::Relaxed); - tail_end.add_range(&Range::new( - read_position, - self.shared.file_size - read_position, - )); - let tail_end = tail_end.intersection(&missing_data); - - if !tail_end.is_empty() { - let range = tail_end.get_range(0); - let offset = range.start; - let length = min(range.length, bytes_to_go); - self.download_range(offset, length); - requests_to_go -= 1; - bytes_to_go -= length; - } else if !missing_data.is_empty() { - // ok, the tail is downloaded, download something fom the beginning. - let range = missing_data.get_range(0); - let offset = range.start; - let length = min(range.length, bytes_to_go); - self.download_range(offset, length); - requests_to_go -= 1; - bytes_to_go -= length; - } else { - return; - } + fn pre_fetch_more_data(&mut self, bytes: usize) -> AudioFileResult { + // determine what is still missing + 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() + .expect(DOWNLOAD_STATUS_POISON_MSG); + missing_data.subtract_range_set(&download_status.downloaded); + missing_data.subtract_range_set(&download_status.requested); } + + // download data from after the current read position first + let mut tail_end = RangeSet::new(); + let read_position = self.shared.read_position(); + tail_end.add_range(&Range::new( + read_position, + self.shared.file_size - read_position, + )); + let tail_end = tail_end.intersection(&missing_data); + + if !tail_end.is_empty() { + let range = tail_end.get_range(0); + let offset = range.start; + let length = min(range.length, bytes); + self.download_range(offset, length)?; + } else if !missing_data.is_empty() { + // ok, the tail is downloaded, download something fom the beginning. + let range = missing_data.get_range(0); + let offset = range.start; + let length = min(range.length, bytes); + self.download_range(offset, length)?; + } + + Ok(()) } - fn handle_file_data(&mut self, data: ReceivedData) -> ControlFlow { + fn handle_file_data(&mut self, data: ReceivedData) -> Result { match data { - ReceivedData::ResponseTime(response_time) => { - // chatty - // trace!("Ping time estimated as: {}ms", response_time.as_millis()); + ReceivedData::Throughput(mut throughput) => { + if throughput < self.params.minimum_throughput { + warn!( + "Throughput {} kbps lower than minimum {}, setting to minimum", + throughput / 1000, + self.params.minimum_throughput / 1000, + ); + throughput = self.params.minimum_throughput; + } + + let old_throughput = self.shared.throughput(); + let avg_throughput = if old_throughput > 0 { + (old_throughput + throughput) / 2 + } else { + throughput + }; + + // print when the new estimate deviates by more than 10% from the last + if f32::abs((avg_throughput as f32 - old_throughput as f32) / old_throughput as f32) + > 0.1 + { + trace!( + "Throughput now estimated as: {} kbps", + avg_throughput / 1000 + ); + } + + self.shared.set_throughput(avg_throughput); + } + ReceivedData::ResponseTime(mut response_time) => { + if response_time > self.params.maximum_assumed_ping_time { + warn!( + "Time to first byte {} ms exceeds maximum {}, setting to maximum", + response_time.as_millis(), + self.params.maximum_assumed_ping_time.as_millis() + ); + response_time = self.params.maximum_assumed_ping_time; + } + + let old_ping_time_ms = self.shared.ping_time().as_millis(); // prune old response times. Keep at most two so we can push a third. while self.network_response_times.len() >= 3 { @@ -278,165 +321,197 @@ 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] + 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!(), } - _ => unreachable!(), }; + // print when the new estimate deviates by more than 10% from the last + if f32::abs( + (ping_time.as_millis() as f32 - old_ping_time_ms as f32) + / old_ping_time_ms as f32, + ) > 0.1 + { + trace!( + "Time to first byte now estimated as: {} ms", + ping_time.as_millis() + ); + } + // store our new estimate for everyone to see - self.shared - .ping_time_ms - .store(ping_time.as_millis() as usize, Ordering::Relaxed); + self.shared.set_ping_time(ping_time); } 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() + .expect(DOWNLOAD_STATUS_POISON_MSG); + download_status.downloaded.add_range(&received_range); + self.shared.cond.notify_all(); - drop(download_status); + download_status.downloaded.contained_length_from_value(0) + >= self.shared.file_size + }; if full { - self.finish(); - return ControlFlow::Break; + self.finish()?; + return Ok(ControlFlow::Break); } } } - ControlFlow::Continue + + Ok(ControlFlow::Continue) } - fn handle_stream_loader_command(&mut self, cmd: StreamLoaderCommand) -> ControlFlow { + fn handle_stream_loader_command( + &mut self, + cmd: StreamLoaderCommand, + ) -> Result { match cmd { StreamLoaderCommand::Fetch(request) => { - self.download_range(request.start, request.length); + self.download_range(request.start, request.length)? } - StreamLoaderCommand::RandomAccessMode() => { - *(self.shared.download_strategy.lock().unwrap()) = DownloadStrategy::RandomAccess(); - } - StreamLoaderCommand::StreamMode() => { - *(self.shared.download_strategy.lock().unwrap()) = DownloadStrategy::Streaming(); - } - StreamLoaderCommand::Close() => return ControlFlow::Break, + StreamLoaderCommand::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.rewind()?; + 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() + .expect(DOWNLOAD_STATUS_POISON_MSG); 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 params = AudioFetchParams::get(); + let mut fetch = AudioFileFetch { - session, + session: session.clone(), shared, output: Some(output), file_data_tx, complete_tx: Some(complete_tx), network_response_times: Vec::with_capacity(3), + + params: params.clone(), }; loop { tokio::select! { cmd = stream_loader_command_rx.recv() => { - if cmd.map_or(true, |cmd| fetch.handle_stream_loader_command(cmd) == ControlFlow::Break) { - break; + match cmd { + Some(cmd) => { + if fetch.handle_stream_loader_command(cmd)? == ControlFlow::Break { + break; + } + } + None => break, + } + } + data = file_data_rx.recv() => { + match data { + Some(data) => { + if fetch.handle_file_data(data)? == ControlFlow::Break { + break; + } + } + None => break, } }, - data = file_data_rx.recv() => { - if data.map_or(true, |data| fetch.handle_file_data(data) == ControlFlow::Break) { - break; - } - } + else => (), } - if fetch.get_download_strategy() == DownloadStrategy::Streaming() { - let number_of_open_requests = - fetch.shared.number_of_open_requests.load(Ordering::SeqCst); - if number_of_open_requests < MAX_PREFETCH_REQUESTS { - let max_requests_to_send = MAX_PREFETCH_REQUESTS - number_of_open_requests; + if fetch.shared.is_download_streaming() && fetch.has_download_slots_available() { + let bytes_pending: usize = { + let download_status = fetch + .shared + .download_status + .lock() + .expect(DOWNLOAD_STATUS_POISON_MSG); - let bytes_pending: usize = { - let download_status = fetch.shared.download_status.lock().unwrap(); - download_status - .requested - .minus(&download_status.downloaded) - .len() - }; + download_status + .requested + .minus(&download_status.downloaded) + .len() + }; - let ping_time_seconds = - Duration::from_millis(fetch.shared.ping_time_ms.load(Ordering::Relaxed) as u64) - .as_secs_f32(); - let download_rate = fetch.session.channel().get_download_rate_estimate(); + let ping_time_seconds = fetch.shared.ping_time().as_secs_f32(); + let throughput = fetch.shared.throughput(); - let desired_pending_bytes = max( - (PREFETCH_THRESHOLD_FACTOR - * ping_time_seconds - * fetch.shared.stream_data_rate as f32) as usize, - (FAST_PREFETCH_THRESHOLD_FACTOR * ping_time_seconds * download_rate as f32) - as usize, - ); + let desired_pending_bytes = max( + (params.prefetch_threshold_factor + * ping_time_seconds + * fetch.shared.bytes_per_second as f32) as usize, + (ping_time_seconds * throughput as f32) as usize, + ); - if bytes_pending < desired_pending_bytes { - fetch.pre_fetch_more_data( - desired_pending_bytes - bytes_pending, - max_requests_to_send, - ); - } + if bytes_pending < desired_pending_bytes { + fetch.pre_fetch_more_data(desired_pending_bytes - bytes_pending)?; } } } + + Ok(()) } diff --git a/audio/src/lib.rs b/audio/src/lib.rs index 4b486bbe..99c41df2 100644 --- a/audio/src/lib.rs +++ b/audio/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unused_io_amount, clippy::too_many_arguments)] - #[macro_use] extern crate log; @@ -9,8 +7,4 @@ mod fetch; mod range_set; pub use decrypt::AudioDecrypt; -pub use fetch::{AudioFile, StreamLoaderController}; -pub use fetch::{ - READ_AHEAD_BEFORE_PLAYBACK, READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK, - READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, -}; +pub use fetch::{AudioFetchParams, AudioFile, AudioFileError, StreamLoaderController}; diff --git a/audio/src/range_set.rs b/audio/src/range_set.rs index f74058a3..3d1dd83a 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, ")") } @@ -58,7 +60,7 @@ impl RangeSet { self.ranges[index] } - pub fn iter(&self) -> Iter { + pub fn iter(&self) -> Iter<'_, Range> { self.ranges.iter() } @@ -227,7 +229,6 @@ impl RangeSet { self.ranges[self_index].end(), other.ranges[other_index].end(), ); - assert!(new_start <= new_end); result.add_range(&Range::new(new_start, new_end - new_start)); if self.ranges[self_index].end() <= other.ranges[other_index].end() { self_index += 1; diff --git a/connect/Cargo.toml b/connect/Cargo.toml index 4daf89f4..08d24f66 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -1,38 +1,33 @@ [package] name = "librespot-connect" -version = "0.3.1" +version.workspace = true +rust-version.workspace = true authors = ["Paul Lietar "] -description = "The discovery and Spotify Connect logic for librespot" -license = "MIT" -repository = "https://github.com/librespot-org/librespot" -edition = "2018" - -[dependencies] -form_urlencoded = "1.0" -futures-util = { version = "0.3.5", default_features = false } -log = "0.4" -protobuf = "2.14.0" -rand = "0.8" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tokio = { version = "1.0", features = ["macros", "sync"] } -tokio-stream = "0.1.1" - -[dependencies.librespot-core] -path = "../core" -version = "0.3.1" - -[dependencies.librespot-playback] -path = "../playback" -version = "0.3.1" - -[dependencies.librespot-protocol] -path = "../protocol" -version = "0.3.1" - -[dependencies.librespot-discovery] -path = "../discovery" -version = "0.3.1" +license.workspace = true +description = "The Spotify Connect logic for librespot" +repository.workspace = true +edition.workspace = true [features] -with-dns-sd = ["librespot-discovery/with-dns-sd"] +# Refer to the workspace Cargo.toml for the list of features +default = ["native-tls"] + +# TLS backend propagation +native-tls = ["librespot-core/native-tls"] +rustls-tls-native-roots = ["librespot-core/rustls-tls-native-roots"] +rustls-tls-webpki-roots = ["librespot-core/rustls-tls-webpki-roots"] + +[dependencies] +librespot-core = { version = "0.7.1", path = "../core", default-features = false } +librespot-playback = { version = "0.7.1", path = "../playback", default-features = false } +librespot-protocol = { version = "0.7.1", path = "../protocol", default-features = false } + +futures-util = { version = "0.3", default-features = false, features = ["std"] } +log = "0.4" +protobuf = "3.7" +rand = { version = "0.9", default-features = false, features = ["small_rng"] } +serde_json = "1.0" +thiserror = "2" +tokio = { version = "1", features = ["macros", "sync"] } +tokio-stream = { version = "0.1", default-features = false } +uuid = { version = "1.18", default-features = false, features = ["v4"] } diff --git a/connect/README.md b/connect/README.md new file mode 100644 index 00000000..015cce3d --- /dev/null +++ b/connect/README.md @@ -0,0 +1,63 @@ +[//]: # (This readme is optimized for inline rustdoc, if some links don't work, they will when included in lib.rs) + +# Connect + +The connect module of librespot. Provides the option to create your own connect device +and stream to it like any other official spotify client. + +The [`Spirc`] is the entrypoint to creating your own connect device. It can be +configured with the given [`ConnectConfig`] options and requires some additional data +to start up the device. + +When creating a new [`Spirc`] it returns two items. The [`Spirc`] itself, which is can +be used as to control the local connect device. And a [`Future`](std::future::Future), +lets name it `SpircTask`, that starts and executes the event loop of the connect device +when awaited. + +A basic example in which the `Spirc` and `SpircTask` is used can be found here: +[`examples/play_connect.rs`](../examples/play_connect.rs). + +# Example + +```rust +use std::{future::Future, thread}; + +use librespot_connect::{ConnectConfig, Spirc}; +use librespot_core::{authentication::Credentials, Error, Session, SessionConfig}; +use librespot_playback::{ + audio_backend, mixer, + config::{AudioFormat, PlayerConfig}, + mixer::{MixerConfig, NoOpVolume}, + player::Player +}; + +async fn create_basic_spirc() -> Result<(), Error> { + let credentials = Credentials::with_access_token("access-token-here"); + let session = Session::new(SessionConfig::default(), None); + + let backend = audio_backend::find(None).expect("will default to rodio"); + + let player = Player::new( + PlayerConfig::default(), + session.clone(), + Box::new(NoOpVolume), + move || { + let format = AudioFormat::default(); + let device = None; + backend(device, format) + }, + ); + + let mixer = mixer::find(None).expect("will default to SoftMixer"); + + let (spirc, spirc_task): (Spirc, _) = Spirc::new( + ConnectConfig::default(), + session, + credentials, + player, + mixer(MixerConfig::default())? + ).await?; + + Ok(()) +} +``` \ No newline at end of file diff --git a/connect/src/context.rs b/connect/src/context.rs deleted file mode 100644 index 63a2aebb..00000000 --- a/connect/src/context.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::core::spotify_id::SpotifyId; -use crate::protocol::spirc::TrackRef; - -use serde::Deserialize; - -#[derive(Deserialize, Debug)] -pub struct StationContext { - pub uri: Option, - pub next_page_url: String, - #[serde(deserialize_with = "deserialize_protobuf_TrackRef")] - pub tracks: Vec, - // Not required for core functionality - // pub seeds: Vec, - // #[serde(rename = "imageUri")] - // pub image_uri: String, - // pub subtitle: Option, - // pub subtitles: Vec, - // #[serde(rename = "subtitleUri")] - // pub subtitle_uri: Option, - // pub title: String, - // #[serde(rename = "titleUri")] - // pub title_uri: String, - // pub related_artists: Vec, -} - -#[derive(Deserialize, Debug)] -pub struct PageContext { - pub uri: String, - pub next_page_url: String, - #[serde(deserialize_with = "deserialize_protobuf_TrackRef")] - pub tracks: Vec, - // Not required for core functionality - // pub url: String, - // // pub restrictions: -} - -#[derive(Deserialize, Debug)] -pub struct TrackContext { - #[serde(rename = "original_gid")] - pub gid: String, - pub uri: String, - pub uid: String, - // Not required for core functionality - // pub album_uri: String, - // pub artist_uri: String, - // pub metadata: MetadataContext, -} - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct ArtistContext { - artist_name: String, - artist_uri: String, - image_uri: String, -} - -#[derive(Deserialize, Debug)] -pub struct MetadataContext { - album_title: String, - artist_name: String, - artist_uri: String, - image_url: String, - title: String, - uid: String, -} - -#[allow(non_snake_case)] -fn deserialize_protobuf_TrackRef<'d, D>(de: D) -> Result, D::Error> -where - D: serde::Deserializer<'d>, -{ - let v: Vec = serde::Deserialize::deserialize(de)?; - let track_vec = 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_uri(v.uri.to_owned()); - - t - }) - .collect::>(); - - Ok(track_vec) -} diff --git a/connect/src/context_resolver.rs b/connect/src/context_resolver.rs new file mode 100644 index 00000000..1bc0c163 --- /dev/null +++ b/connect/src/context_resolver.rs @@ -0,0 +1,346 @@ +use crate::{ + core::{Error, Session}, + protocol::{ + autoplay_context_request::AutoplayContextRequest, context::Context, + transfer_state::TransferState, + }, + state::{ConnectState, context::ContextType}, +}; +use std::{ + cmp::PartialEq, + collections::{HashMap, VecDeque}, + fmt::{Display, Formatter}, + hash::Hash, + time::Duration, +}; +use thiserror::Error as ThisError; +use tokio::time::Instant; + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +enum Resolve { + Uri(String), + Context(Context), +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(super) enum ContextAction { + Append, + Replace, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(super) struct ResolveContext { + resolve: Resolve, + fallback: Option, + update: ContextType, + action: ContextAction, +} + +impl ResolveContext { + fn append_context(uri: impl Into) -> Self { + Self { + resolve: Resolve::Uri(uri.into()), + fallback: None, + update: ContextType::Default, + action: ContextAction::Append, + } + } + + pub fn from_uri( + uri: impl Into, + fallback: impl Into, + update: ContextType, + action: ContextAction, + ) -> Self { + let fallback_uri = fallback.into(); + Self { + resolve: Resolve::Uri(uri.into()), + fallback: (!fallback_uri.is_empty()).then_some(fallback_uri), + update, + action, + } + } + + pub fn from_context(context: Context, update: ContextType, action: ContextAction) -> Self { + Self { + resolve: Resolve::Context(context), + fallback: None, + update, + action, + } + } + + /// the uri which should be used to resolve the context, might not be the context uri + fn resolve_uri(&self) -> Option<&str> { + // it's important to call this always, or at least for every ResolveContext + // otherwise we might not even check if we need to fallback and just use the fallback uri + match self.resolve { + Resolve::Uri(ref uri) => ConnectState::valid_resolve_uri(uri), + Resolve::Context(ref ctx) => { + ConnectState::find_valid_uri(ctx.uri.as_deref(), ctx.pages.first()) + } + } + .or(self.fallback.as_deref()) + } + + /// the actual context uri + fn context_uri(&self) -> &str { + match self.resolve { + Resolve::Uri(ref uri) => uri, + Resolve::Context(ref ctx) => ctx.uri.as_deref().unwrap_or_default(), + } + } +} + +impl Display for ResolveContext { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "resolve_uri: <{:?}>, context_uri: <{}>, update: <{:?}>", + self.resolve_uri(), + self.context_uri(), + self.update, + ) + } +} + +#[derive(Debug, ThisError)] +enum ContextResolverError { + #[error("no next context to resolve")] + NoNext, + #[error("tried appending context with {0} pages")] + UnexpectedPagesSize(usize), + #[error("tried resolving not allowed context: {0:?}")] + NotAllowedContext(String), +} + +impl From for Error { + fn from(value: ContextResolverError) -> Self { + Error::failed_precondition(value) + } +} + +pub struct ContextResolver { + session: Session, + queue: VecDeque, + unavailable_contexts: HashMap, +} + +// time after which an unavailable context is retried +const RETRY_UNAVAILABLE: Duration = Duration::from_secs(3600); + +impl ContextResolver { + pub fn new(session: Session) -> Self { + Self { + session, + queue: VecDeque::new(), + unavailable_contexts: HashMap::new(), + } + } + + pub fn add(&mut self, resolve: ResolveContext) { + let last_try = self + .unavailable_contexts + .get(&resolve) + .map(|i| i.duration_since(Instant::now())); + + let last_try = if matches!(last_try, Some(last_try) if last_try > RETRY_UNAVAILABLE) { + let _ = self.unavailable_contexts.remove(&resolve); + debug!( + "context was requested {}s ago, trying again to resolve the requested context", + last_try.expect("checked by condition").as_secs() + ); + None + } else { + last_try + }; + + if last_try.is_some() { + debug!("tried loading unavailable context: {resolve}"); + return; + } else if self.queue.contains(&resolve) { + debug!("update for {resolve} is already added"); + return; + } else { + trace!( + "added {} to resolver queue", + resolve.resolve_uri().unwrap_or(resolve.context_uri()) + ) + } + + self.queue.push_back(resolve) + } + + pub fn add_list(&mut self, resolve: Vec) { + for resolve in resolve { + self.add(resolve) + } + } + + pub fn remove_used_and_invalid(&mut self) { + if let Some((_, _, remove)) = self.find_next() { + let _ = self.queue.drain(0..remove); // remove invalid + } + self.queue.pop_front(); // remove used + } + + pub fn clear(&mut self) { + self.queue = VecDeque::new() + } + + fn find_next(&self) -> Option<(&ResolveContext, &str, usize)> { + for idx in 0..self.queue.len() { + let next = self.queue.get(idx)?; + match next.resolve_uri() { + None => { + warn!("skipped {idx} because of invalid resolve_uri: {next}"); + continue; + } + Some(uri) => return Some((next, uri, idx)), + } + } + None + } + + pub fn has_next(&self) -> bool { + self.find_next().is_some() + } + + pub async fn get_next_context( + &self, + recent_track_uri: impl Fn() -> Vec, + ) -> Result { + let (next, resolve_uri, _) = self.find_next().ok_or(ContextResolverError::NoNext)?; + + match next.update { + ContextType::Default => { + let mut ctx = self.session.spclient().get_context(resolve_uri).await; + if let Ok(ctx) = ctx.as_mut() { + ctx.uri = Some(next.context_uri().to_string()); + ctx.url = ctx.uri.as_ref().map(|s| format!("context://{s}")); + } + + ctx + } + ContextType::Autoplay => { + if resolve_uri.contains("spotify:show:") || resolve_uri.contains("spotify:episode:") + { + // autoplay is not supported for podcasts + Err(ContextResolverError::NotAllowedContext( + resolve_uri.to_string(), + ))? + } + + let request = AutoplayContextRequest { + context_uri: Some(resolve_uri.to_string()), + recent_track_uri: recent_track_uri(), + ..Default::default() + }; + self.session.spclient().get_autoplay_context(&request).await + } + } + } + + pub fn mark_next_unavailable(&mut self) { + if let Some((next, _, _)) = self.find_next() { + self.unavailable_contexts + .insert(next.clone(), Instant::now()); + } + } + + pub fn apply_next_context( + &self, + state: &mut ConnectState, + mut context: Context, + ) -> Result>, Error> { + let (next, _, _) = self.find_next().ok_or(ContextResolverError::NoNext)?; + + let remaining = match next.action { + ContextAction::Append if context.pages.len() == 1 => state + .fill_context_from_page(context.pages.remove(0)) + .map(|_| None), + ContextAction::Replace => { + let remaining = state.update_context(context, next.update); + if let Resolve::Context(ref ctx) = next.resolve { + state.merge_context(ctx.pages.clone().pop()); + } + + remaining + } + ContextAction::Append => { + warn!("unexpected page size: {context:#?}"); + Err(ContextResolverError::UnexpectedPagesSize(context.pages.len()).into()) + } + }?; + + Ok(remaining.map(|remaining| { + remaining + .into_iter() + .map(ResolveContext::append_context) + .collect::>() + })) + } + + pub fn try_finish( + &self, + state: &mut ConnectState, + transfer_state: &mut Option, + ) -> bool { + let (next, _, _) = match self.find_next() { + None => return false, + Some(next) => next, + }; + + // when there is only one update type, we are the last of our kind, so we should update the state + if self + .queue + .iter() + .filter(|resolve| resolve.update == next.update) + .count() + != 1 + { + return false; + } + + match (next.update, state.active_context) { + (ContextType::Default, ContextType::Default) | (ContextType::Autoplay, _) => { + debug!( + "last item of type <{:?}>, finishing state setup", + next.update + ); + } + (ContextType::Default, _) => { + debug!("skipped finishing default, because it isn't the active context"); + return false; + } + } + + let active_ctx = state.get_context(state.active_context); + let res = if let Some(transfer_state) = transfer_state.take() { + state.finish_transfer(transfer_state) + } else if state.shuffling_context() && next.update == ContextType::Default { + state.shuffle_new() + } else if matches!(active_ctx, Ok(ctx) if ctx.index.track == 0) { + // has context, and context is not touched + // when the index is not zero, the next index was already evaluated elsewhere + let ctx = active_ctx.expect("checked by precondition"); + let idx = ConnectState::find_index_in_context(ctx, |t| { + state.current_track(|c| t.uri == c.uri) + }) + .ok(); + + state.reset_playback_to_position(idx) + } else { + state.fill_up_next_tracks() + }; + + if let Err(why) = res { + error!("setup of state failed: {why}, last used resolve {next:#?}") + } + + state.update_restrictions(); + state.update_queue_revision(); + + true + } +} diff --git a/connect/src/discovery.rs b/connect/src/discovery.rs deleted file mode 100644 index 8ce3f4f0..00000000 --- a/connect/src/discovery.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::io; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use futures_util::Stream; -use librespot_core::authentication::Credentials; -use librespot_core::config::ConnectConfig; - -pub struct DiscoveryStream(librespot_discovery::Discovery); - -impl Stream for DiscoveryStream { - type Item = Credentials; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.0).poll_next(cx) - } -} - -pub fn discovery( - config: ConnectConfig, - device_id: String, - port: u16, -) -> io::Result { - librespot_discovery::Discovery::builder(device_id) - .device_type(config.device_type) - .port(port) - .name(config.name) - .launch() - .map(DiscoveryStream) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) -} diff --git a/connect/src/lib.rs b/connect/src/lib.rs index 267bf1b8..ba00aa4c 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -1,3 +1,6 @@ +#![warn(missing_docs)] +#![doc=include_str!("../README.md")] + #[macro_use] extern crate log; @@ -5,10 +8,12 @@ use librespot_core as core; use librespot_playback as playback; use librespot_protocol as protocol; -pub mod context; -#[deprecated( - since = "0.2.1", - note = "Please use the crate `librespot_discovery` instead." -)] -pub mod discovery; -pub mod spirc; +mod context_resolver; +mod model; +mod shuffle_vec; +mod spirc; +mod state; + +pub use model::*; +pub use spirc::*; +pub use state::*; diff --git a/connect/src/model.rs b/connect/src/model.rs new file mode 100644 index 00000000..10f25f1b --- /dev/null +++ b/connect/src/model.rs @@ -0,0 +1,167 @@ +use crate::{ + core::dealer::protocol::SkipTo, protocol::context_player_options::ContextPlayerOptionOverrides, +}; + +use std::ops::Deref; + +/// Request for loading playback +#[derive(Debug, Clone)] +pub struct LoadRequest { + pub(super) context: PlayContext, + pub(super) options: LoadRequestOptions, +} + +impl Deref for LoadRequest { + type Target = LoadRequestOptions; + + fn deref(&self) -> &Self::Target { + &self.options + } +} + +#[derive(Debug, Clone)] +pub(super) enum PlayContext { + Uri(String), + Tracks(Vec), +} + +/// The parameters for creating a load request +#[derive(Debug, Default, Clone)] +pub struct LoadRequestOptions { + /// Whether the given tracks should immediately start playing, or just be initially loaded. + pub start_playing: bool, + /// Start the playback at a specific point of the track. + /// + /// The provided value is used as milliseconds. Providing a value greater + /// than the track duration will start the track at the beginning. + pub seek_to: u32, + /// Options that decide how the context starts playing + pub context_options: Option, + /// Decides the starting position in the given context. + /// + /// If the provided item doesn't exist or is out of range, + /// the playback starts at the beginning of the context. + /// + /// If `None` is provided and `shuffle` is `true`, a random track is played, otherwise the first + pub playing_track: Option, +} + +/// The options which decide how the playback is started +/// +/// Separated into an `enum` to exclude the other variants from being used +/// simultaneously, as they are not compatible. +#[derive(Debug, Clone)] +pub enum LoadContextOptions { + /// Starts the context with options + Options(Options), + /// Starts the playback as the autoplay variant of the context + /// + /// This is the same as finishing a context and + /// automatically continuing playback of similar tracks + Autoplay, +} + +/// The available options that indicate how to start the context +#[derive(Debug, Default, Clone)] +pub struct Options { + /// Start the context in shuffle mode + pub shuffle: bool, + /// Start the context in repeat mode + pub repeat: bool, + /// Start the context, repeating the first track until skipped or manually disabled + pub repeat_track: bool, +} + +impl From for Options { + fn from(value: ContextPlayerOptionOverrides) -> Self { + Self { + shuffle: value.shuffling_context.unwrap_or_default(), + repeat: value.repeating_context.unwrap_or_default(), + repeat_track: value.repeating_track.unwrap_or_default(), + } + } +} + +impl LoadRequest { + /// Create a load request from a `context_uri` + /// + /// For supported `context_uri` see [`SpClient::get_context`](librespot_core::spclient::SpClient::get_context) + /// + /// Equivalent to using [`/me/player/play`](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback) + /// and providing `context_uri` + pub fn from_context_uri(context_uri: String, options: LoadRequestOptions) -> Self { + Self { + context: PlayContext::Uri(context_uri), + options, + } + } + + /// Create a load request from a set of `tracks` + /// + /// Equivalent to using [`/me/player/play`](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback) + /// and providing `uris` + pub fn from_tracks(tracks: Vec, options: LoadRequestOptions) -> Self { + Self { + context: PlayContext::Tracks(tracks), + options, + } + } +} + +/// An item that represent a track to play +#[derive(Debug, Clone)] +pub enum PlayingTrack { + /// Represent the track at a given index. + Index(u32), + /// Represent the uri of a track. + Uri(String), + #[doc(hidden)] + /// Represent an internal identifier from spotify. + /// + /// The internal identifier is not the id contained in the uri. And rather + /// an unrelated id probably unique in spotify's internal database. But that's + /// just speculation. + /// + /// This identifier is not available by any public api. It's used for varies in + /// any spotify client, like sorting, displaying which track is currently played + /// and skipping to a track. Mobile uses it pretty intensively but also web and + /// desktop seem to make use of it. + Uid(String), +} + +impl TryFrom for PlayingTrack { + type Error = (); + + fn try_from(value: SkipTo) -> Result { + // order of checks is important, as the index can be 0, but still has an uid or uri provided, + // so we only use the index as last resort + if let Some(uri) = value.track_uri { + Ok(PlayingTrack::Uri(uri)) + } else if let Some(uid) = value.track_uid { + Ok(PlayingTrack::Uid(uid)) + } else if let Some(index) = value.track_index { + Ok(PlayingTrack::Index(index)) + } else { + Err(()) + } + } +} + +#[derive(Debug)] +pub(super) enum SpircPlayStatus { + Stopped, + LoadingPlay { + position_ms: u32, + }, + LoadingPause { + position_ms: u32, + }, + Playing { + nominal_start_time: i64, + preloading_of_next_track_triggered: bool, + }, + Paused { + position_ms: u32, + preloading_of_next_track_triggered: bool, + }, +} diff --git a/connect/src/shuffle_vec.rs b/connect/src/shuffle_vec.rs new file mode 100644 index 00000000..595a392b --- /dev/null +++ b/connect/src/shuffle_vec.rs @@ -0,0 +1,198 @@ +use rand::{Rng, SeedableRng, rngs::SmallRng}; +use std::{ + ops::{Deref, DerefMut}, + vec::IntoIter, +}; + +#[derive(Debug, Clone, Default)] +pub struct ShuffleVec { + vec: Vec, + indices: Option>, + /// This is primarily necessary to ensure that shuffle does not behave out of place. + /// + /// For that reason we swap the first track with the currently playing track. By that we ensure + /// that the shuffle state is consistent between resets of the state because the first track is + /// always the track with which we started playing when switching to shuffle. + original_first_position: Option, +} + +impl PartialEq for ShuffleVec { + fn eq(&self, other: &Self) -> bool { + self.vec == other.vec + } +} + +impl Deref for ShuffleVec { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.vec + } +} + +impl DerefMut for ShuffleVec { + fn deref_mut(&mut self) -> &mut Self::Target { + self.vec.as_mut() + } +} + +impl IntoIterator for ShuffleVec { + type Item = T; + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.vec.into_iter() + } +} + +impl From> for ShuffleVec { + fn from(vec: Vec) -> Self { + Self { + vec, + original_first_position: None, + indices: None, + } + } +} + +impl ShuffleVec { + pub fn shuffle_with_seed bool>(&mut self, seed: u64, is_first: F) { + self.shuffle_with_rng(SmallRng::seed_from_u64(seed), is_first) + } + + pub fn shuffle_with_rng bool>(&mut self, mut rng: impl Rng, is_first: F) { + if self.vec.len() <= 1 { + info!("skipped shuffling for less or equal one item"); + return; + } + + if self.indices.is_some() { + self.unshuffle() + } + + let indices: Vec<_> = { + (1..self.vec.len()) + .rev() + .map(|i| rng.random_range(0..i + 1)) + .collect() + }; + + for (i, &rnd_ind) in (1..self.vec.len()).rev().zip(&indices) { + self.vec.swap(i, rnd_ind); + } + + self.indices = Some(indices); + + self.original_first_position = self.vec.iter().position(is_first); + if let Some(first_pos) = self.original_first_position { + self.vec.swap(0, first_pos) + } + } + + pub fn unshuffle(&mut self) { + let indices = match self.indices.take() { + Some(indices) => indices, + None => return, + }; + + if let Some(first_pos) = self.original_first_position { + self.vec.swap(0, first_pos); + self.original_first_position = None; + } + + for i in 1..self.vec.len() { + match indices.get(self.vec.len() - i - 1) { + None => return, + Some(n) => self.vec.swap(*n, i), + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use rand::Rng; + use std::ops::Range; + + fn base(range: Range) -> (ShuffleVec, u64) { + let seed = rand::rng().random_range(0..10_000_000_000_000); + + let vec = range.collect::>(); + (vec.into(), seed) + } + + #[test] + fn test_shuffle_without_first() { + let (base_vec, seed) = base(0..100); + + let mut shuffled_vec = base_vec.clone(); + shuffled_vec.shuffle_with_seed(seed, |_| false); + + let mut different_shuffled_vec = base_vec.clone(); + different_shuffled_vec.shuffle_with_seed(seed, |_| false); + + assert_eq!( + shuffled_vec, different_shuffled_vec, + "shuffling with the same seed has the same result" + ); + + let mut unshuffled_vec = shuffled_vec.clone(); + unshuffled_vec.unshuffle(); + + assert_eq!( + base_vec, unshuffled_vec, + "unshuffle restores the original state" + ); + } + + #[test] + fn test_shuffle_with_first() { + const MAX_RANGE: usize = 200; + + let (base_vec, seed) = base(0..MAX_RANGE); + let rand_first = rand::rng().random_range(0..MAX_RANGE); + + let mut shuffled_with_first = base_vec.clone(); + shuffled_with_first.shuffle_with_seed(seed, |i| i == &rand_first); + + assert_eq!( + Some(&rand_first), + shuffled_with_first.first(), + "after shuffling the first is expected to be the given item" + ); + + let mut shuffled_without_first = base_vec.clone(); + shuffled_without_first.shuffle_with_seed(seed, |_| false); + + let mut switched_positions = Vec::with_capacity(2); + for (i, without_first_value) in shuffled_without_first.iter().enumerate() { + if without_first_value != &shuffled_with_first[i] { + switched_positions.push(i); + } else { + assert_eq!( + without_first_value, &shuffled_with_first[i], + "shuffling with the same seed has the same result" + ); + } + } + + assert_eq!( + switched_positions.len(), + 2, + "only the switched positions should be different" + ); + + assert_eq!( + shuffled_with_first[switched_positions[0]], + shuffled_without_first[switched_positions[1]], + "the switched values should be equal" + ); + + assert_eq!( + shuffled_with_first[switched_positions[1]], + shuffled_without_first[switched_positions[0]], + "the switched values should be equal" + ) + } +} diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index d644e2b0..43702d8a 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,74 +1,118 @@ -use std::future::Future; -use std::pin::Pin; -use std::time::{SystemTime, UNIX_EPOCH}; +use crate::{ + LoadContextOptions, LoadRequestOptions, PlayContext, + context_resolver::{ContextAction, ContextResolver, ResolveContext}, + core::{ + Error, Session, SpotifyUri, + authentication::Credentials, + dealer::{ + manager::{BoxedStream, BoxedStreamResult, Reply, RequestReply}, + protocol::{Command, FallbackWrapper, Message, Request}, + }, + session::UserAttributes, + }, + model::{LoadRequest, PlayingTrack, SpircPlayStatus}, + playback::{ + mixer::Mixer, + player::{Player, PlayerEvent, PlayerEventChannel}, + }, + protocol::{ + connect::{Cluster, ClusterUpdate, LogoutCommand, SetVolumeCommand}, + context::Context, + explicit_content_pubsub::UserAttributesUpdate, + playlist4_external::PlaylistModificationInfo, + social_connect_v2::SessionUpdate, + transfer_state::TransferState, + user_attributes::UserAttributesMutation, + }, + state::{ + context::{ContextType, ResetContext}, + provider::IsProvider, + {ConnectConfig, ConnectState}, + }, +}; +use futures_util::StreamExt; +use librespot_protocol::context_page::ContextPage; +use protobuf::MessageField; +use std::{ + future::Future, + sync::Arc, + sync::atomic::{AtomicUsize, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; +use tokio::{sync::mpsc, time::sleep}; -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}; -use futures_util::stream::FusedStream; -use futures_util::{FutureExt, StreamExt}; -use protobuf::{self, Message}; -use rand::seq::SliceRandom; -use tokio::sync::mpsc; -use tokio_stream::wrappers::UnboundedReceiverStream; - -enum SpircPlayStatus { - Stopped, - LoadingPlay { - position_ms: u32, - }, - LoadingPause { - position_ms: u32, - }, - Playing { - nominal_start_time: i64, - preloading_of_next_track_triggered: bool, - }, - Paused { - position_ms: u32, - preloading_of_next_track_triggered: bool, - }, +#[derive(Debug, Error)] +enum SpircError { + #[error("response payload empty")] + NoData, + #[error("{0} had no uri")] + NoUri(&'static str), + #[error("message pushed for another URI")] + InvalidUri(String), + #[error("failed to put connect state for new device")] + FailedDealerSetup, + #[error("unknown endpoint: {0:#?}")] + UnknownEndpoint(serde_json::Value), } -type BoxedFuture = Pin + Send>>; -type BoxedStream = Pin + Send>>; +impl From for Error { + fn from(err: SpircError) -> Self { + use SpircError::*; + match err { + NoData | NoUri(_) => Error::unavailable(err), + InvalidUri(_) | FailedDealerSetup => Error::aborted(err), + UnknownEndpoint(_) => Error::unimplemented(err), + } + } +} struct SpircTask { - player: Player, - mixer: Box, - config: SpircTaskConfig, + player: Arc, + mixer: Arc, - sequence: SeqGenerator, + /// the state management object + connect_state: ConnectState, - ident: String, - device: DeviceState, - state: State, play_request_id: Option, play_status: SpircPlayStatus, - subscription: BoxedStream, - sender: MercurySender, + connection_id_update: BoxedStreamResult, + connect_state_update: BoxedStreamResult, + connect_state_volume_update: BoxedStreamResult, + connect_state_logout_request: BoxedStreamResult, + playlist_update: BoxedStreamResult, + session_update: BoxedStreamResult>, + connect_state_command: BoxedStream, + user_attributes_update: BoxedStreamResult, + user_attributes_mutation: BoxedStreamResult, + commands: Option>, player_events: Option, + context_resolver: ContextResolver, + shutdown: bool, session: Session, - context_fut: BoxedFuture>, - autoplay_fut: BoxedFuture>, - context: Option, + + /// is set when transferring, and used after resolving the contexts to finish the transfer + pub transfer_state: Option, + + /// when set to true, it will update the volume after [VOLUME_UPDATE_DELAY], + /// when no other future resolves, otherwise resets the delay + update_volume: bool, + + /// when set to true, it will update the volume after [UPDATE_STATE_DELAY], + /// when no other future resolves, otherwise resets the delay + update_state: bool, + + spirc_id: usize, } -pub enum SpircCommand { +static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug)] +enum SpircCommand { Play, PlayPause, Pause, @@ -77,319 +121,423 @@ pub enum SpircCommand { VolumeUp, VolumeDown, Shutdown, - Shuffle, + Shuffle(bool), + Repeat(bool), + RepeatTrack(bool), + Disconnect { pause: bool }, + SetPosition(u32), + SetVolume(u16), + Activate, + Load(LoadRequest), } -struct SpircTaskConfig { - autoplay: bool, -} +const CONTEXT_FETCH_THRESHOLD: usize = 2; -const CONTEXT_TRACKS_HISTORY: usize = 10; -const CONTEXT_FETCH_THRESHOLD: u32 = 5; - -const VOLUME_STEPS: i64 = 64; -const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS +// delay to update volume after a certain amount of time, instead on each update request +const VOLUME_UPDATE_DELAY: Duration = Duration::from_millis(500); +// to reduce updates to remote, we group some request by waiting for a set amount of time +const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200); +/// The spotify connect handle pub struct Spirc { commands: mpsc::UnboundedSender, } -fn initial_state() -> State { - let mut frame = protocol::spirc::State::new(); - frame.set_repeat(false); - frame.set_shuffle(false); - frame.set_status(PlayStatus::kPlayStatusStop); - frame.set_position_ms(0); - frame.set_position_measured_at(0); - frame -} - -fn initial_device_state(config: ConnectConfig) -> DeviceState { - { - let mut msg = DeviceState::new(); - msg.set_sw_version(version::VERSION_STRING.to_string()); - msg.set_is_active(false); - msg.set_can_play(true); - msg.set_volume(0); - msg.set_name(config.name); - { - let repeated = msg.mut_capabilities(); - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kCanBePlayer); - { - let repeated = msg.mut_intValue(); - repeated.push(1) - }; - msg - }; - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kDeviceType); - { - let repeated = msg.mut_intValue(); - repeated.push(config.device_type as i64) - }; - msg - }; - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kGaiaEqConnectId); - { - let repeated = msg.mut_intValue(); - repeated.push(1) - }; - msg - }; - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kSupportsLogout); - { - let repeated = msg.mut_intValue(); - repeated.push(0) - }; - msg - }; - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kIsObservable); - { - let repeated = msg.mut_intValue(); - repeated.push(1) - }; - msg - }; - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kVolumeSteps); - { - let repeated = msg.mut_intValue(); - if config.has_volume_ctrl { - repeated.push(VOLUME_STEPS) - } else { - repeated.push(0) - } - }; - msg - }; - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kSupportsPlaylistV2); - { - let repeated = msg.mut_intValue(); - repeated.push(1) - }; - msg - }; - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kSupportedContexts); - { - let repeated = msg.mut_stringValue(); - repeated.push(::std::convert::Into::into("album")); - repeated.push(::std::convert::Into::into("playlist")); - repeated.push(::std::convert::Into::into("search")); - repeated.push(::std::convert::Into::into("inbox")); - repeated.push(::std::convert::Into::into("toplist")); - repeated.push(::std::convert::Into::into("starred")); - repeated.push(::std::convert::Into::into("publishedstarred")); - repeated.push(::std::convert::Into::into("track")) - }; - msg - }; - { - let msg = repeated.push_default(); - msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes); - { - let repeated = msg.mut_stringValue(); - repeated.push(::std::convert::Into::into("audio/local")); - repeated.push(::std::convert::Into::into("audio/track")); - repeated.push(::std::convert::Into::into("audio/episode")); - repeated.push(::std::convert::Into::into("local")); - repeated.push(::std::convert::Into::into("track")) - }; - msg - }; - }; - msg - } -} - -fn url_encode(bytes: impl AsRef<[u8]>) -> String { - form_urlencoded::byte_serialize(bytes.as_ref()).collect() -} - impl Spirc { - pub fn new( + /// Initializes a new spotify connect device + /// + /// The returned tuple consists out of a handle to the [`Spirc`] that + /// can control the local connect device when active. And a [`Future`] + /// which represents the [`Spirc`] event loop that processes the whole + /// connect device logic. + pub async fn new( config: ConnectConfig, session: Session, - player: Player, - mixer: Box, - ) -> (Spirc, impl Future) { - debug!("new Spirc[{}]", session.session_id()); + credentials: Credentials, + player: Arc, + mixer: Arc, + ) -> Result<(Spirc, impl Future), Error> { + fn extract_connection_id(msg: Message) -> Result { + let connection_id = msg + .headers + .get("Spotify-Connection-Id") + .ok_or_else(|| SpircError::InvalidUri(msg.uri.clone()))?; + Ok(connection_id.to_owned()) + } - let ident = session.device_id().to_owned(); + let spirc_id = SPIRC_COUNTER.fetch_add(1, Ordering::AcqRel); + debug!("new Spirc[{spirc_id}]"); - // Uri updated in response to issue #288 - debug!("canonical_username: {}", &session.username()); - let uri = format!("hm://remote/user/{}/", url_encode(&session.username())); + let connect_state = ConnectState::new(config, &session); - let subscription = Box::pin( - session - .mercury() - .subscribe(uri.clone()) - .map(Result::unwrap) - .map(UnboundedReceiverStream::new) - .flatten_stream() - .map(|response| -> Frame { - let data = response.payload.first().unwrap(); - Frame::parse_from_bytes(data).unwrap() - }), - ); + let connection_id_update = session + .dealer() + .listen_for("hm://pusher/v1/connections/", extract_connection_id)?; - let sender = session.mercury().sender(uri); + let connect_state_update = session + .dealer() + .listen_for("hm://connect-state/v1/cluster", Message::from_raw)?; + + let connect_state_volume_update = session + .dealer() + .listen_for("hm://connect-state/v1/connect/volume", Message::from_raw)?; + + let connect_state_logout_request = session + .dealer() + .listen_for("hm://connect-state/v1/connect/logout", Message::from_raw)?; + + let playlist_update = session + .dealer() + .listen_for("hm://playlist/v2/playlist/", Message::from_raw)?; + + let session_update = session + .dealer() + .listen_for("social-connect/v2/session_update", Message::try_from_json)?; + + let user_attributes_update = session + .dealer() + .listen_for("spotify:user:attributes:update", Message::from_raw)?; + + // can be trigger by toggling autoplay in a desktop client + let user_attributes_mutation = session + .dealer() + .listen_for("spotify:user:attributes:mutated", Message::from_raw)?; + + let connect_state_command = session + .dealer() + .handle_for("hm://connect-state/v1/player/command")?; + + // pre-acquire client_token, preventing multiple request while running + let _ = session.spclient().client_token().await?; + + // Connect *after* all message listeners are registered + session.connect(credentials, true).await?; + + // pre-acquire access_token (we need to be authenticated to retrieve a token) + let _ = session.login5().auth_token().await?; let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let initial_volume = config.initial_volume; - let task_config = SpircTaskConfig { - autoplay: config.autoplay, - }; - - let device = initial_device_state(config); - let player_events = player.get_player_event_channel(); let mut task = SpircTask { player, mixer, - config: task_config, - sequence: SeqGenerator::new(1), + connect_state, - ident, - - device, - state: initial_state(), play_request_id: None, play_status: SpircPlayStatus::Stopped, - subscription, - sender, + connection_id_update, + connect_state_update, + connect_state_volume_update, + connect_state_logout_request, + playlist_update, + session_update, + connect_state_command, + user_attributes_update, + user_attributes_mutation, commands: Some(cmd_rx), player_events: Some(player_events), + context_resolver: ContextResolver::new(session.clone()), + shutdown: false, session, - context_fut: Box::pin(future::pending()), - autoplay_fut: Box::pin(future::pending()), - context: None, - }; + transfer_state: None, + update_volume: false, + update_state: false, - if let Some(volume) = initial_volume { - task.set_volume(volume); - } else { - let current_volume = task.mixer.volume(); - task.set_volume(current_volume); - } + spirc_id, + }; let spirc = Spirc { commands: cmd_tx }; - task.hello(); + let initial_volume = task.connect_state.device_info().volume; + task.connect_state.set_volume(0); - (spirc, task.run()) + match initial_volume.try_into() { + Ok(volume) => { + task.set_volume(volume); + // we don't want to update the volume initially, + // we just want to set the mixer to the correct volume + task.update_volume = false; + } + Err(why) => error!("failed to update initial volume: {why}"), + }; + + Ok((spirc, task.run())) } - pub fn play(&self) { - let _ = self.commands.send(SpircCommand::Play); + /// Safely shutdowns the spirc. + /// + /// This pauses the playback, disconnects the connect device and + /// bring the future initially returned to an end. + pub fn shutdown(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Shutdown)?) } - pub fn play_pause(&self) { - let _ = self.commands.send(SpircCommand::PlayPause); + + /// Resumes the playback + /// + /// Does nothing if we are not the active device, or it isn't paused. + pub fn play(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Play)?) } - pub fn pause(&self) { - let _ = self.commands.send(SpircCommand::Pause); + + /// Resumes or pauses the playback + /// + /// Does nothing if we are not the active device. + pub fn play_pause(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::PlayPause)?) } - pub fn prev(&self) { - let _ = self.commands.send(SpircCommand::Prev); + + /// Pauses the playback + /// + /// Does nothing if we are not the active device, or if it isn't playing. + pub fn pause(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Pause)?) } - pub fn next(&self) { - let _ = self.commands.send(SpircCommand::Next); + + /// Seeks to the beginning or skips to the previous track. + /// + /// Seeks to the beginning when the current track position + /// is greater than 3 seconds. + /// + /// Does nothing if we are not the active device. + pub fn prev(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Prev)?) } - pub fn volume_up(&self) { - let _ = self.commands.send(SpircCommand::VolumeUp); + + /// Skips to the next track. + /// + /// Does nothing if we are not the active device. + pub fn next(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Next)?) } - pub fn volume_down(&self) { - let _ = self.commands.send(SpircCommand::VolumeDown); + + /// Increases the volume by configured steps of [ConnectConfig]. + /// + /// Does nothing if we are not the active device. + pub fn volume_up(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::VolumeUp)?) } - pub fn shutdown(&self) { - let _ = self.commands.send(SpircCommand::Shutdown); + + /// Decreases the volume by configured steps of [ConnectConfig]. + /// + /// Does nothing if we are not the active device. + pub fn volume_down(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::VolumeDown)?) } - pub fn shuffle(&self) { - let _ = self.commands.send(SpircCommand::Shuffle); + + /// Shuffles the playback according to the value. + /// + /// If true shuffles/reshuffles the playback. Otherwise, does + /// nothing (if not shuffled) or unshuffles the playback while + /// resuming at the position of the current track. + /// + /// Does nothing if we are not the active device. + pub fn shuffle(&self, shuffle: bool) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Shuffle(shuffle))?) + } + + /// Repeats the playback context according to the value. + /// + /// Does nothing if we are not the active device. + pub fn repeat(&self, repeat: bool) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Repeat(repeat))?) + } + + /// Repeats the current track if true. + /// + /// Does nothing if we are not the active device. + /// + /// Skipping to the next track disables the repeating. + pub fn repeat_track(&self, repeat: bool) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::RepeatTrack(repeat))?) + } + + /// Update the volume to the given value. + /// + /// Does nothing if we are not the active device. + pub fn set_volume(&self, volume: u16) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::SetVolume(volume))?) + } + + /// Updates the position to the given value. + /// + /// Does nothing if we are not the active device. + /// + /// If value is greater than the track duration, + /// the update is ignored. + pub fn set_position_ms(&self, position_ms: u32) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::SetPosition(position_ms))?) + } + + /// Load a new context and replace the current. + /// + /// Does nothing if we are not the active device. + /// + /// Does not overwrite the queue. + pub fn load(&self, command: LoadRequest) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Load(command))?) + } + + /// Disconnects the current device and pauses the playback according the value. + /// + /// Does nothing if we are not the active device. + pub fn disconnect(&self, pause: bool) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Disconnect { pause })?) + } + + /// Acquires the control as active connect device. + /// + /// Does nothing if we are not the active device. + pub fn activate(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Activate)?) } } impl SpircTask { async fn run(mut self) { + // simplify unwrapping of received item or parsed result + macro_rules! unwrap { + ( $next:expr, |$some:ident| $use_some:expr ) => { + match $next { + Some($some) => $use_some, + None => { + error!("{} selected, but none received", stringify!($next)); + break; + } + } + }; + ( $next:expr, match |$ok:ident| $use_ok:expr ) => { + unwrap!($next, |$ok| match $ok { + Ok($ok) => $use_ok, + Err(why) => error!("could not parse {}: {}", stringify!($ok), why), + }) + }; + } + + if let Err(why) = self.session.dealer().start().await { + error!("starting dealer failed: {why}"); + return; + } + while !self.session.is_invalid() && !self.shutdown { let commands = self.commands.as_mut(); let player_events = self.player_events.as_mut(); + + // when state and volume update have a higher priority than context resolving + // because of that the context resolving has to wait, so that the other tasks can finish + let allow_context_resolving = !self.update_state && !self.update_volume; + tokio::select! { - frame = self.subscription.next() => match frame { - Some(frame) => self.handle_frame(frame), - None => { - error!("subscription terminated"); + // startup of the dealer requires a connection_id, which is retrieved at the very beginning + connection_id_update = self.connection_id_update.next() => unwrap! { + connection_id_update, + match |connection_id| if let Err(why) = self.handle_connection_id_update(connection_id).await { + error!("failed handling connection id update: {why}"); break; } }, - cmd = async { commands.unwrap().recv().await }, if commands.is_some() => if let Some(cmd) = cmd { - self.handle_command(cmd); - }, - event = async { player_events.unwrap().recv().await }, if player_events.is_some() => if let Some(event) = event { - self.handle_player_event(event) - }, - result = self.sender.flush(), if !self.sender.is_flushed() => if result.is_err() { - error!("Cannot flush spirc event sender."); - break; - }, - context = &mut self.context_fut, if !self.context_fut.is_terminated() => { - match context { - Ok(value) => { - let r_context = serde_json::from_value::(value); - self.context = match r_context { - Ok(context) => { - info!( - "Resolved {:?} tracks from <{:?}>", - context.tracks.len(), - self.state.get_context_uri(), - ); - Some(context) - } - Err(e) => { - error!("Unable to parse JSONContext {:?}", e); - None - } - }; - // It needn't be so verbose - can be as simple as - // if let Some(ref context) = r_context { - // info!("Got {:?} tracks from <{}>", context.tracks.len(), context.uri); - // } - // self.context = r_context; - }, - Err(err) => { - error!("ContextError: {:?}", err) - } + // main dealer update of any remote device updates + cluster_update = self.connect_state_update.next() => unwrap! { + cluster_update, + match |cluster_update| if let Err(e) = self.handle_cluster_update(cluster_update).await { + error!("could not dispatch connect state update: {e}"); } }, - autoplay = &mut self.autoplay_fut, if !self.autoplay_fut.is_terminated() => { - match autoplay { - Ok(autoplay_station_uri) => { - info!("Autoplay uri resolved to <{:?}>", autoplay_station_uri); - self.context_fut = self.resolve_station(&autoplay_station_uri); - }, - Err(err) => { - error!("AutoplayError: {:?}", err) + // main dealer request handling (dealer expects an answer) + request = self.connect_state_command.next() => unwrap! { + request, + |request| if let Err(e) = self.handle_connect_state_request(request).await { + error!("couldn't handle connect state command: {e}"); + } + }, + // volume request handling is send separately (it's more like a fire forget) + volume_update = self.connect_state_volume_update.next() => unwrap! { + volume_update, + match |volume_update| match volume_update.volume.try_into() { + Ok(volume) => self.set_volume(volume), + Err(why) => error!("can't update volume, failed to parse i32 to u16: {why}") + } + }, + logout_request = self.connect_state_logout_request.next() => unwrap! { + logout_request, + |logout_request| { + error!("received logout request, currently not supported: {logout_request:#?}"); + // todo: call logout handling + } + }, + playlist_update = self.playlist_update.next() => unwrap! { + playlist_update, + match |playlist_update| if let Err(why) = self.handle_playlist_modification(playlist_update) { + error!("failed to handle playlist modification: {why}") + } + }, + user_attributes_update = self.user_attributes_update.next() => unwrap! { + user_attributes_update, + match |attributes| self.handle_user_attributes_update(attributes) + }, + user_attributes_mutation = self.user_attributes_mutation.next() => unwrap! { + user_attributes_mutation, + match |attributes| self.handle_user_attributes_mutation(attributes) + }, + session_update = self.session_update.next() => unwrap! { + session_update, + match |session_update| self.handle_session_update(session_update) + }, + cmd = async { commands?.recv().await }, if commands.is_some() => if let Some(cmd) = cmd { + if let Err(e) = self.handle_command(cmd).await { + debug!("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}"); + } + }, + _ = async { sleep(UPDATE_STATE_DELAY).await }, if self.update_state => { + self.update_state = false; + + if let Err(why) = self.notify().await { + error!("state update: {why}") + } + }, + _ = async { sleep(VOLUME_UPDATE_DELAY).await }, if self.update_volume => { + self.update_volume = false; + + info!("delayed volume update for all devices: volume is now {}", self.connect_state.device_info().volume); + if let Err(why) = self.connect_state.notify_volume_changed(&self.session).await { + error!("error updating connect state for volume update: {why}") + } + + // for some reason the web-player does need two separate updates, so that the + // position of the current track is retained, other clients also send a state + // update before they send the volume update + if let Err(why) = self.notify().await { + error!("error updating connect state for volume update: {why}") + } + }, + // context resolver handling, the idea/reason behind it the following: + // + // when we request a context that has multiple pages (for example an artist) + // resolving all pages at once can take around ~1-30sec, when we resolve + // everything at once that would block our main loop for that time + // + // to circumvent this behavior, we request each context separately here and + // finish after we received our last item of a type + next_context = async { + self.context_resolver.get_next_context(|| { + self.connect_state.recent_track_uris() + }).await + }, if allow_context_resolving && self.context_resolver.has_next() => { + let update_state = self.handle_next_context(next_context); + if update_state { + if let Err(why) = self.notify().await { + error!("update after context resolving failed: {why}") } } }, @@ -397,340 +545,827 @@ impl SpircTask { } } - if self.sender.flush().await.is_err() { - warn!("Cannot flush spirc event sender."); + if !self.shutdown && self.connect_state.is_active() { + warn!("unexpected shutdown"); + if let Err(why) = self.handle_disconnect().await { + error!("error during disconnecting: {why}") + } } + + // this should clear the active session id, leaving an empty state + if let Err(why) = self.session.spclient().delete_connect_state_request().await { + error!("error during connect state deletion: {why}") + }; + + self.session.dealer().close().await; } - fn now_ms(&mut self) -> i64 { - let dur = match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(dur) => dur, - Err(err) => err.duration(), + fn handle_next_context(&mut self, next_context: Result) -> bool { + let next_context = match next_context { + Err(why) => { + self.context_resolver.mark_next_unavailable(); + self.context_resolver.remove_used_and_invalid(); + error!("{why}"); + return false; + } + Ok(ctx) => ctx, }; + debug!("handling next context {:?}", next_context.uri); + + match self + .context_resolver + .apply_next_context(&mut self.connect_state, next_context) + { + Ok(remaining) => { + if let Some(remaining) = remaining { + self.context_resolver.add_list(remaining) + } + } + Err(why) => { + error!("{why}") + } + } + + let update_state = if self + .context_resolver + .try_finish(&mut self.connect_state, &mut self.transfer_state) + { + self.add_autoplay_resolving_when_required(); + true + } else { + false + }; + + self.context_resolver.remove_used_and_invalid(); + update_state + } + + // todo: is the time_delta still necessary? + fn now_ms(&self) -> i64 { + let dur = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|err| err.duration()); + dur.as_millis() as i64 + 1000 * self.session.time_delta() } - fn update_state_position(&mut self, position_ms: u32) { - let now = self.now_ms(); - self.state.set_position_measured_at(now as u64); - self.state.set_position_ms(position_ms); - } - - fn handle_command(&mut self, cmd: SpircCommand) { - let active = self.device.get_is_active(); + async fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> { + trace!("Received SpircCommand::{cmd:?}"); match cmd { - SpircCommand::Play => { - if active { - self.handle_play(); - self.notify(None, true); - } else { - CommandSender::new(self, MessageType::kMessageTypePlay).send(); - } - } - SpircCommand::PlayPause => { - if active { - self.handle_play_pause(); - self.notify(None, true); - } else { - CommandSender::new(self, MessageType::kMessageTypePlayPause).send(); - } - } - SpircCommand::Pause => { - if active { - self.handle_pause(); - self.notify(None, true); - } else { - CommandSender::new(self, MessageType::kMessageTypePause).send(); - } - } - SpircCommand::Prev => { - if active { - self.handle_prev(); - self.notify(None, true); - } else { - CommandSender::new(self, MessageType::kMessageTypePrev).send(); - } - } - SpircCommand::Next => { - if active { - self.handle_next(); - self.notify(None, true); - } else { - CommandSender::new(self, MessageType::kMessageTypeNext).send(); - } - } - SpircCommand::VolumeUp => { - if active { - self.handle_volume_up(); - self.notify(None, true); - } else { - CommandSender::new(self, MessageType::kMessageTypeVolumeUp).send(); - } - } - SpircCommand::VolumeDown => { - if active { - self.handle_volume_down(); - self.notify(None, true); - } else { - CommandSender::new(self, MessageType::kMessageTypeVolumeDown).send(); - } - } SpircCommand::Shutdown => { - CommandSender::new(self, MessageType::kMessageTypeGoodbye).send(); + trace!("Received SpircCommand::Shutdown"); + self.handle_pause(); + self.handle_disconnect().await?; self.shutdown = true; if let Some(rx) = self.commands.as_mut() { rx.close() } } - SpircCommand::Shuffle => { - CommandSender::new(self, MessageType::kMessageTypeShuffle).send(); + SpircCommand::Activate if !self.connect_state.is_active() => { + trace!("Received SpircCommand::{cmd:?}"); + self.handle_activate(); + return self.notify().await; } - } + SpircCommand::Activate => { + warn!("SpircCommand::{cmd:?} will be ignored while already active") + } + _ if !self.connect_state.is_active() => { + warn!("SpircCommand::{cmd:?} will be ignored while Not Active") + } + SpircCommand::Disconnect { pause } => { + if pause { + self.handle_pause() + } + return self.handle_disconnect().await; + } + SpircCommand::Play => self.handle_play(), + SpircCommand::PlayPause => self.handle_play_pause(), + SpircCommand::Pause => self.handle_pause(), + SpircCommand::Prev => self.handle_prev()?, + SpircCommand::Next => self.handle_next(None)?, + SpircCommand::VolumeUp => self.handle_volume_up(), + SpircCommand::VolumeDown => self.handle_volume_down(), + SpircCommand::Shuffle(shuffle) => self.handle_shuffle(shuffle)?, + SpircCommand::Repeat(repeat) => self.handle_repeat_context(repeat)?, + SpircCommand::RepeatTrack(repeat) => self.handle_repeat_track(repeat), + SpircCommand::SetPosition(position) => self.handle_seek(position), + SpircCommand::SetVolume(volume) => self.set_volume(volume), + SpircCommand::Load(command) => self.handle_load(command, None).await?, + }; + + self.notify().await } - fn handle_player_event(&mut self, event: PlayerEvent) { + fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { + if let PlayerEvent::TrackChanged { audio_item } = event { + self.connect_state.update_duration(audio_item.duration_ms); + self.update_state = true; + return Ok(()); + } + + // update play_request_id + if let PlayerEvent::PlayRequestIdChanged { play_request_id } = event { + self.play_request_id = Some(play_request_id); + return Ok(()); + } + + let is_current_track = matches! { + (event.get_play_request_id(), self.play_request_id), + (Some(event_id), Some(current_id)) if event_id == current_id + }; + // 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 // mess with it. - if let Some(play_request_id) = event.get_play_request_id() { - 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::Playing { position_ms, .. } => { - let new_nominal_start_time = self.now_ms() - position_ms as i64; - match self.play_status { - SpircPlayStatus::Playing { - ref mut nominal_start_time, - .. - } => { - 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); - } - } - 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, - }; - } - _ => (), - }; - trace!("==> kPlayStatusPlay"); - } - PlayerEvent::Paused { - position_ms: new_position_ms, + if !is_current_track { + return Ok(()); + } + + match event { + PlayerEvent::EndOfTrack { .. } => { + let next_track = self + .connect_state + .repeat_track() + .then(|| self.connect_state.current_track(|t| t.uri.clone())); + + self.handle_next(next_track)? + } + PlayerEvent::Loading { .. } => match self.play_status { + SpircPlayStatus::LoadingPlay { position_ms } => { + self.connect_state + .update_position(position_ms, self.now_ms()); + trace!("==> LoadingPlay"); + } + SpircPlayStatus::LoadingPause { position_ms } => { + self.connect_state + .update_position(position_ms, self.now_ms()); + trace!("==> LoadingPause"); + } + _ => { + self.connect_state.update_position(0, self.now_ms()); + trace!("==> Loading"); + } + }, + PlayerEvent::Seeked { position_ms, .. } => { + trace!("==> Seeked"); + self.connect_state + .update_position(position_ms, self.now_ms()) + } + PlayerEvent::Playing { position_ms, .. } + | PlayerEvent::PositionCorrection { position_ms, .. } => { + trace!("==> Playing"); + let new_nominal_start_time = self.now_ms() - position_ms as i64; + match self.play_status { + SpircPlayStatus::Playing { + ref mut nominal_start_time, .. } => { - match self.play_status { - SpircPlayStatus::Paused { - ref mut position_ms, - .. - } => { - if *position_ms != new_position_ms { - *position_ms = new_position_ms; - self.update_state_position(new_position_ms); - self.notify(None, true); - } - } - 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, - }; - } - _ => (), + if (*nominal_start_time - new_nominal_start_time).abs() > 100 { + *nominal_start_time = new_nominal_start_time; + self.connect_state + .update_position(position_ms, self.now_ms()); + } else { + return 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::TimeToPreloadNextTrack { .. } => self.handle_preload_next_track(), - PlayerEvent::Unavailable { track_id, .. } => self.handle_unavailable(track_id), - _ => (), + SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { + self.connect_state + .update_position(position_ms, self.now_ms()); + self.play_status = SpircPlayStatus::Playing { + nominal_start_time: new_nominal_start_time, + preloading_of_next_track_triggered: false, + }; + } + _ => return Ok(()), } } + PlayerEvent::Paused { + position_ms: new_position_ms, + .. + } => { + trace!("==> Paused"); + match self.play_status { + SpircPlayStatus::Paused { .. } | SpircPlayStatus::Playing { .. } => { + self.connect_state + .update_position(new_position_ms, self.now_ms()); + self.play_status = SpircPlayStatus::Paused { + position_ms: new_position_ms, + preloading_of_next_track_triggered: false, + }; + } + SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { + self.connect_state + .update_position(new_position_ms, self.now_ms()); + self.play_status = SpircPlayStatus::Paused { + position_ms: new_position_ms, + preloading_of_next_track_triggered: false, + }; + } + _ => return Ok(()), + } + } + PlayerEvent::Stopped { .. } => { + trace!("==> Stopped"); + match self.play_status { + SpircPlayStatus::Stopped => return Ok(()), + _ => self.play_status = SpircPlayStatus::Stopped, + } + } + PlayerEvent::TimeToPreloadNextTrack { .. } => { + self.handle_preload_next_track(); + return Ok(()); + } + PlayerEvent::Unavailable { track_id, .. } => { + self.handle_unavailable(&track_id)?; + if self.connect_state.current_track(|t| &t.uri) == &track_id.to_uri()? { + self.handle_next(None)? + } + } + _ => return Ok(()), + } + + self.update_state = true; + Ok(()) + } + + async fn handle_connection_id_update(&mut self, connection_id: String) -> Result<(), Error> { + trace!("Received connection ID update: {connection_id:?}"); + self.session.set_connection_id(&connection_id); + + let cluster = match self + .connect_state + .notify_new_device_appeared(&self.session) + .await + { + Ok(res) => Cluster::parse_from_bytes(&res).ok(), + Err(why) => { + error!("{why:?}"); + None + } + } + .ok_or(SpircError::FailedDealerSetup)?; + + debug!( + "successfully put connect state for {} with connection-id {connection_id}", + self.session.device_id() + ); + + let same_session = cluster.player_state.session_id == self.session.session_id() + || cluster.player_state.session_id.is_empty(); + if !cluster.active_device_id.is_empty() || !same_session { + info!( + "active device is <{}> with session <{}>", + cluster.active_device_id, cluster.player_state.session_id + ); + return Ok(()); + } else if cluster.transfer_data.is_empty() { + debug!("got empty transfer state, do nothing"); + return Ok(()); + } else { + info!( + "trying to take over control automatically, session_id: {}", + cluster.player_state.session_id + ) + } + + use protobuf::Message; + + match TransferState::parse_from_bytes(&cluster.transfer_data) { + Ok(transfer_state) => self.handle_transfer(transfer_state)?, + Err(why) => error!("failed to take over control: {why}"), + } + + Ok(()) + } + + fn handle_user_attributes_update(&mut self, update: UserAttributesUpdate) { + trace!("Received attributes update: {update:#?}"); + let attributes: UserAttributes = update + .pairs + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect(); + self.session.set_user_attributes(attributes) + } + + fn handle_user_attributes_mutation(&mut self, mutation: UserAttributesMutation) { + for attribute in mutation.fields.iter() { + let key = &attribute.name; + + if key == "autoplay" && self.session.config().autoplay.is_some() { + trace!("Autoplay override active. Ignoring mutation."); + continue; + } + + 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, {key} was {old_value} is now {new_value}"); + + if key == "filter-explicit-content" && new_value == "1" { + self.player + .emit_filter_explicit_content_changed_event(matches!(new_value, "1")); + } + + if key == "autoplay" && old_value != new_value { + self.player + .emit_auto_play_changed_event(matches!(new_value, "1")); + + self.add_autoplay_resolving_when_required() + } + } else { + trace!("Received attribute mutation for {key} but key was not found!"); + } } } - fn handle_frame(&mut self, frame: Frame) { - let state_string = match frame.get_state().get_status() { - PlayStatus::kPlayStatusLoading => "kPlayStatusLoading", - PlayStatus::kPlayStatusPause => "kPlayStatusPause", - PlayStatus::kPlayStatusStop => "kPlayStatusStop", - PlayStatus::kPlayStatusPlay => "kPlayStatusPlay", - }; + async fn handle_cluster_update( + &mut self, + mut cluster_update: ClusterUpdate, + ) -> Result<(), Error> { + let reason = cluster_update.update_reason.enum_value(); + let device_ids = cluster_update.devices_that_changed.join(", "); debug!( - "{:?} {:?} {} {} {} {}", - frame.get_typ(), - frame.get_device_state().get_name(), - frame.get_ident(), - frame.get_seq_nr(), - frame.get_state_update_id(), - state_string, + "cluster update: {reason:?} from {device_ids}, active device: {}", + cluster_update.cluster.active_device_id ); - if frame.get_ident() == self.ident - || (!frame.get_recipient().is_empty() && !frame.get_recipient().contains(&self.ident)) + if let Some(cluster) = cluster_update.cluster.take() { + let became_inactive = self.connect_state.is_active() + && cluster.active_device_id != self.session.device_id(); + if became_inactive { + info!("device became inactive"); + self.handle_disconnect().await?; + self.handle_stop(); + } else if self.connect_state.is_active() { + // fixme: workaround fix, because of missing information why it behaves like it does + // background: when another device sends a connect-state update, some player's position de-syncs + // tried: providing session_id, playback_id, track-metadata "track_player" + self.update_state = true; + } + } else if self.connect_state.is_active() { + self.connect_state.became_inactive(&self.session).await?; + } + + Ok(()) + } + + async fn handle_connect_state_request( + &mut self, + (request, sender): RequestReply, + ) -> Result<(), Error> { + self.connect_state.set_last_command(request.clone()); + + debug!( + "handling: '{}' from {}", + request.command, request.sent_by_device_id + ); + + let response = match self.handle_request(request).await { + Ok(_) => Reply::Success, + Err(why) => { + error!("failed to handle request: {why}"); + Reply::Failure + } + }; + + sender.send(response).map_err(Into::into) + } + + async fn handle_request(&mut self, request: Request) -> Result<(), Error> { + use Command::*; + + match request.command { + // errors and unknown commands + Transfer(transfer) if transfer.data.is_none() => { + warn!("transfer endpoint didn't contain any data to transfer"); + Err(SpircError::NoData)? + } + Unknown(unknown) => Err(SpircError::UnknownEndpoint(unknown))?, + // implicit update of the connect_state + UpdateContext(update_context) => { + if matches!(update_context.context.uri, Some(ref uri) if uri != self.connect_state.context_uri()) + { + debug!( + "ignoring context update for <{:?}>, because it isn't the current context <{}>", + update_context.context.uri, + self.connect_state.context_uri() + ) + } else { + self.context_resolver.add(ResolveContext::from_context( + update_context.context, + ContextType::Default, + ContextAction::Replace, + )) + } + return Ok(()); + } + // modification and update of the connect_state + Transfer(transfer) => { + self.handle_transfer(transfer.data.expect("by condition checked"))?; + return self.notify().await; + } + Play(mut play) => { + if !self.connect_state.is_active() { + self.handle_activate() + } + + let context = match play.context.uri { + Some(s) => PlayContext::Uri(s), + None if !play.context.pages.is_empty() => PlayContext::Tracks( + play.context + .pages + .iter() + .cloned() + .flat_map(|p| p.tracks) + .flat_map(|t| t.uri) + .collect(), + ), + None => Err(SpircError::NoUri("context"))?, + }; + + let context_options = play + .options + .player_options_override + .map(Into::into) + .map(LoadContextOptions::Options); + + self.handle_load( + LoadRequest { + context, + options: LoadRequestOptions { + start_playing: true, + seek_to: play.options.seek_to.unwrap_or_default(), + playing_track: play.options.skip_to.and_then(|s| s.try_into().ok()), + context_options, + }, + }, + play.context.pages.pop(), + ) + .await?; + + self.connect_state.set_origin(play.play_origin) + } + Pause(_) => self.handle_pause(), + SeekTo(seek_to) => { + // for some reason the position is stored in value, not in position + trace!("seek to {seek_to:?}"); + self.handle_seek(seek_to.value) + } + SetShufflingContext(shuffle) => self.handle_shuffle(shuffle.value)?, + SetRepeatingContext(repeat_context) => { + self.handle_repeat_context(repeat_context.value)? + } + SetRepeatingTrack(repeat_track) => self.handle_repeat_track(repeat_track.value), + AddToQueue(add_to_queue) => self.connect_state.add_to_queue(add_to_queue.track, true), + SetQueue(set_queue) => self.connect_state.handle_set_queue(set_queue), + SetOptions(set_options) => { + if let Some(repeat_context) = set_options.repeating_context { + self.handle_repeat_context(repeat_context)? + } + + if let Some(repeat_track) = set_options.repeating_track { + self.handle_repeat_track(repeat_track) + } + + let shuffle = set_options.shuffling_context; + if let Some(shuffle) = shuffle { + self.handle_shuffle(shuffle)?; + } + } + SkipNext(skip_next) => self.handle_next(skip_next.track.map(|t| t.uri))?, + SkipPrev(_) => self.handle_prev()?, + Resume(_) if matches!(self.play_status, SpircPlayStatus::Stopped) => { + self.load_track(true, 0)? + } + Resume(_) => self.handle_play(), + } + + self.update_state = true; + Ok(()) + } + + fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { + let mut ctx_uri = match transfer.current_session.context.uri { + None => Err(SpircError::NoUri("transfer context"))?, + // can apparently happen when a state is transferred stared with "uris" via the api + Some(ref uri) if uri == "-" => String::new(), + Some(ref uri) => uri.clone(), + }; + + self.connect_state + .reset_context(ResetContext::WhenDifferent(&ctx_uri)); + + match self.connect_state.current_track_from_transfer(&transfer) { + Err(why) => warn!("didn't find initial track: {why}"), + Ok(track) => { + debug!("found initial track <{}>", track.uri); + self.connect_state.set_track(track) + } + }; + + let autoplay = self.connect_state.current_track(|t| t.is_autoplay()); + if autoplay { + ctx_uri = ctx_uri.replace("station:", ""); + } + + let fallback = self.connect_state.current_track(|t| &t.uri).clone(); + let load_from_context_uri = !ctx_uri.is_empty(); + + if load_from_context_uri { + self.context_resolver.add(ResolveContext::from_uri( + ctx_uri.clone(), + &fallback, + ContextType::Default, + ContextAction::Replace, + )); + } else { + self.load_context_from_tracks( + transfer + .current_session + .context + .pages + .iter() + .cloned() + .flat_map(|p| p.tracks) + .collect::>(), + )? + } + + self.context_resolver.add(ResolveContext::from_uri( + ctx_uri.clone(), + &fallback, + ContextType::Default, + ContextAction::Replace, + )); + + self.handle_activate(); + + let timestamp = self.now_ms(); + let state = &mut self.connect_state; + state.handle_initial_transfer(&mut transfer); + + // adjust active context, so resolve knows for which context it should set up the state + state.active_context = if autoplay { + ContextType::Autoplay + } else { + ContextType::Default + }; + + // update position if the track continued playing + let transfer_timestamp = transfer.playback.timestamp.unwrap_or_default(); + let position = match transfer.playback.position_as_of_timestamp { + Some(position) if transfer.playback.is_paused.unwrap_or_default() => position.into(), + // update position if the track continued playing + Some(position) if position > 0 => { + let time_since_position_update = timestamp - transfer_timestamp; + i64::from(position) + time_since_position_update + } + _ => 0, + }; + + let is_playing = !transfer.playback.is_paused(); + + if self.connect_state.current_track(|t| t.is_autoplay()) || autoplay { + debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); + + self.context_resolver.add(ResolveContext::from_uri( + ctx_uri, + fallback, + ContextType::Autoplay, + ContextAction::Replace, + )) + } + + if load_from_context_uri { + self.transfer_state = Some(transfer); + } else { + let ctx = self.connect_state.get_context(ContextType::Default)?; + let idx = ConnectState::find_index_in_context(ctx, |pt| { + self.connect_state.current_track(|t| pt.uri == t.uri) + })?; + self.connect_state.reset_playback_to_position(Some(idx))?; + } + + self.load_track(is_playing, position.try_into()?) + } + + async fn handle_disconnect(&mut self) -> Result<(), Error> { + self.context_resolver.clear(); + + self.play_status = SpircPlayStatus::Stopped {}; + self.connect_state + .update_position_in_relation(self.now_ms()); + self.notify().await?; + + self.connect_state.became_inactive(&self.session).await?; + + self.player + .emit_session_disconnected_event(self.session.connection_id(), self.session.username()); + + Ok(()) + } + + fn handle_stop(&mut self) { + self.player.stop(); + self.connect_state.update_position(0, self.now_ms()); + self.connect_state.clear_next_tracks(); + + if let Err(why) = self.connect_state.reset_playback_to_position(None) { + warn!("failed filling up next_track during stopping: {why}") + } + } + + fn handle_activate(&mut self) { + self.connect_state.set_active(true); + self.player + .emit_session_connected_event(self.session.connection_id(), self.session.username()); + self.player.emit_session_client_changed_event( + self.session.client_id(), + self.session.client_name(), + self.session.client_brand_name(), + self.session.client_model_name(), + ); + + self.player + .emit_volume_changed_event(self.connect_state.device_info().volume as u16); + + self.player + .emit_auto_play_changed_event(self.session.autoplay()); + + self.player + .emit_filter_explicit_content_changed_event(self.session.filter_explicit_content()); + + self.player + .emit_shuffle_changed_event(self.connect_state.shuffling_context()); + + self.player.emit_repeat_changed_event( + self.connect_state.repeat_context(), + self.connect_state.repeat_track(), + ); + } + + async fn handle_load( + &mut self, + cmd: LoadRequest, + page: Option, + ) -> Result<(), Error> { + self.connect_state + .reset_context(if let PlayContext::Uri(ref uri) = cmd.context { + ResetContext::WhenDifferent(uri) + } else { + ResetContext::Completely + }); + + self.connect_state.reset_options(); + + let autoplay = matches!(cmd.context_options, Some(LoadContextOptions::Autoplay)); + match cmd.context { + PlayContext::Uri(uri) => { + self.load_context_from_uri(uri, page.as_ref(), autoplay) + .await? + } + PlayContext::Tracks(tracks) => self.load_context_from_tracks(tracks)?, + } + + let cmd_options = cmd.options; + + self.connect_state.set_active_context(ContextType::Default); + + // for play commands with skip by uid, the context of the command contains + // tracks with uri and uid, so we merge the new context with the resolved/existing context + self.connect_state.merge_context(page); + + // load here, so that we clear the queue only after we definitely retrieved a new context + self.connect_state.clear_next_tracks(); + self.connect_state.clear_restrictions(); + + debug!("play track <{:?}>", cmd_options.playing_track); + + let index = match cmd_options.playing_track { + None => None, + Some(ref playing_track) => Some(match playing_track { + PlayingTrack::Index(i) => *i as usize, + PlayingTrack::Uri(uri) => { + let ctx = self.connect_state.get_context(ContextType::Default)?; + ConnectState::find_index_in_context(ctx, |t| &t.uri == uri)? + } + PlayingTrack::Uid(uid) => { + let ctx = self.connect_state.get_context(ContextType::Default)?; + ConnectState::find_index_in_context(ctx, |t| &t.uid == uid)? + } + }), + }; + + if let Some(LoadContextOptions::Options(ref options)) = cmd_options.context_options { + debug!( + "loading with shuffle: <{}>, repeat track: <{}> context: <{}>", + options.shuffle, options.repeat, options.repeat_track + ); + + self.connect_state.set_shuffle(options.shuffle); + self.connect_state.set_repeat_context(options.repeat); + self.connect_state.set_repeat_track(options.repeat_track); + } + + if matches!(cmd_options.context_options, Some(LoadContextOptions::Options(ref o)) if o.shuffle) { - return; + if let Some(index) = index { + self.connect_state.set_current_track(index)?; + } else { + self.connect_state.set_current_track_random()?; + } + + if self.context_resolver.has_next() { + self.connect_state.update_queue_revision() + } else { + self.connect_state.shuffle_new()?; + self.add_autoplay_resolving_when_required(); + } + } else { + self.connect_state + .set_current_track(index.unwrap_or_default())?; + self.connect_state.reset_playback_to_position(index)?; + self.add_autoplay_resolving_when_required(); } - match frame.get_typ() { - MessageType::kMessageTypeHello => { - self.notify(Some(frame.get_ident()), true); - } - - MessageType::kMessageTypeLoad => { - if !self.device.get_is_active() { - let now = self.now_ms(); - self.device.set_is_active(true); - self.device.set_became_active_at(now); - } - - self.update_tracks(&frame); - - 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()); - } else { - info!("No more tracks left in queue"); - self.state.set_status(PlayStatus::kPlayStatusStop); - self.player.stop(); - self.play_status = SpircPlayStatus::Stopped; - } - - self.notify(None, true); - } - - MessageType::kMessageTypePlay => { - self.handle_play(); - self.notify(None, true); - } - - MessageType::kMessageTypePlayPause => { - self.handle_play_pause(); - self.notify(None, true); - } - - MessageType::kMessageTypePause => { - self.handle_pause(); - self.notify(None, true); - } - - MessageType::kMessageTypeNext => { - self.handle_next(); - self.notify(None, true); - } - - MessageType::kMessageTypePrev => { - self.handle_prev(); - self.notify(None, true); - } - - MessageType::kMessageTypeVolumeUp => { - self.handle_volume_up(); - self.notify(None, true); - } - - MessageType::kMessageTypeVolumeDown => { - self.handle_volume_down(); - self.notify(None, true); - } - - MessageType::kMessageTypeRepeat => { - self.state.set_repeat(frame.get_state().get_repeat()); - self.notify(None, true); - } - - MessageType::kMessageTypeShuffle => { - self.state.set_shuffle(frame.get_state().get_shuffle()); - if self.state.get_shuffle() { - let current_index = self.state.get_playing_track_index(); - { - let tracks = self.state.mut_track(); - tracks.swap(0, current_index as usize); - if let Some((_, rest)) = tracks.split_first_mut() { - let mut rng = rand::thread_rng(); - rest.shuffle(&mut rng); - } - } - self.state.set_playing_track_index(0); - } else { - let context = self.state.get_context_uri(); - debug!("{:?}", context); - } - self.notify(None, true); - } - - MessageType::kMessageTypeSeek => { - self.handle_seek(frame.get_position()); - self.notify(None, true); - } - - MessageType::kMessageTypeReplace => { - self.update_tracks(&frame); - self.notify(None, true); - - if let SpircPlayStatus::Playing { - preloading_of_next_track_triggered, - .. - } - | SpircPlayStatus::Paused { - preloading_of_next_track_triggered, - .. - } = self.play_status - { - if preloading_of_next_track_triggered { - // Get the next track_id in the playlist - if let Some(track_id) = self.preview_next_track() { - self.player.preload(track_id); - } - } - } - } - - MessageType::kMessageTypeVolume => { - self.set_volume(frame.get_volume() as u16); - self.notify(None, true); - } - - MessageType::kMessageTypeNotify => { - if self.device.get_is_active() - && frame.get_device_state().get_is_active() - && self.device.get_became_active_at() - <= frame.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; - } - } - - _ => (), + if self.connect_state.current_track(MessageField::is_some) { + self.load_track(cmd_options.start_playing, cmd_options.seek_to)?; + } else { + info!("No active track, stopping"); + self.handle_stop() } + + Ok(()) + } + + async fn load_context_from_uri( + &mut self, + context_uri: String, + page: Option<&ContextPage>, + autoplay: bool, + ) -> Result<(), Error> { + if !self.connect_state.is_active() { + self.handle_activate(); + } + + let update_context = if autoplay { + ContextType::Autoplay + } else { + ContextType::Default + }; + + self.connect_state.set_active_context(update_context); + + let fallback = match page { + // check that the uri is valid or the page has a valid uri that can be used + Some(page) => match ConnectState::find_valid_uri(Some(&context_uri), Some(page)) { + Some(ctx_uri) => ctx_uri, + None => return Err(SpircError::InvalidUri(context_uri).into()), + }, + // when there is no page, the uri should be valid + None => &context_uri, + }; + + let current_context_uri = self.connect_state.context_uri(); + + if current_context_uri == &context_uri && fallback == context_uri { + debug!("context <{current_context_uri}> didn't change, no resolving required") + } else { + debug!("resolving context for load command"); + self.context_resolver.clear(); + self.context_resolver.add(ResolveContext::from_uri( + &context_uri, + fallback, + update_context, + ContextAction::Replace, + )); + let context = self.context_resolver.get_next_context(Vec::new).await; + self.handle_next_context(context); + } + + Ok(()) + } + + fn load_context_from_tracks(&mut self, tracks: impl Into) -> Result<(), Error> { + let ctx = Context { + pages: vec![tracks.into()], + ..Default::default() + }; + + let _ = self + .connect_state + .update_context(ctx, ContextType::Default)?; + + Ok(()) } fn handle_play(&mut self) { @@ -739,16 +1374,11 @@ 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); + self.connect_state + .update_position(position_ms, self.now_ms()); self.play_status = SpircPlayStatus::Playing { - nominal_start_time: self.now_ms() as i64 - position_ms as i64, + nominal_start_time: self.now_ms() - position_ms as i64, preloading_of_next_track_triggered, }; } @@ -756,8 +1386,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) { @@ -779,9 +1414,9 @@ impl SpircTask { preloading_of_next_track_triggered, } => { self.player.pause(); - self.state.set_status(PlayStatus::kPlayStatusPause); let position_ms = (self.now_ms() - nominal_start_time) as u32; - self.update_state_position(position_ms); + self.connect_state + .update_position(position_ms, self.now_ms()); self.play_status = SpircPlayStatus::Paused { position_ms, preloading_of_next_track_triggered, @@ -796,7 +1431,14 @@ impl SpircTask { } fn handle_seek(&mut self, position_ms: u32) { - self.update_state_position(position_ms); + let duration = self.connect_state.player().duration; + if i64::from(position_ms) > duration { + warn!("tried to seek to {position_ms}ms of {duration}ms"); + return; + } + + self.connect_state + .update_position(position_ms, self.now_ms()); self.player.seek(position_ms); let now = self.now_ms(); match self.play_status { @@ -818,23 +1460,21 @@ impl SpircTask { }; } - fn consume_queued_track(&mut self) -> usize { - // Removes current track if it is queued - // Returns the index of the next track - let current_index = self.state.get_playing_track_index() as usize; - if (current_index < self.state.get_track().len()) - && self.state.get_track()[current_index].get_queued() - { - self.state.mut_track().remove(current_index); - current_index - } else { - current_index + 1 - } + fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> { + self.player.emit_shuffle_changed_event(shuffle); + self.connect_state.handle_shuffle(shuffle) } - fn preview_next_track(&mut self) -> Option { - self.get_track_id_to_play_from_playlist(self.state.get_playing_track_index() + 1) - .map(|(track_id, _)| track_id) + fn handle_repeat_context(&mut self, repeat: bool) -> Result<(), Error> { + self.player + .emit_repeat_changed_event(repeat, self.connect_state.repeat_track()); + self.connect_state.handle_set_repeat_context(repeat) + } + + fn handle_repeat_track(&mut self, repeat: bool) { + self.player + .emit_repeat_changed_event(self.connect_state.repeat_context(), repeat); + self.connect_state.set_repeat_track(repeat); } fn handle_preload_next_track(&mut self) { @@ -849,139 +1489,199 @@ impl SpircTask { .. } => { *preloading_of_next_track_triggered = true; - if let Some(track_id) = self.preview_next_track() { - self.player.preload(track_id); - } } - SpircPlayStatus::LoadingPause { .. } - | SpircPlayStatus::LoadingPlay { .. } - | SpircPlayStatus::Stopped => (), + _ => (), + } + + if let Some(track_id) = self.connect_state.preview_next_track() { + self.player.preload(track_id); } } // Mark unavailable tracks so we can skip them later - fn handle_unavailable(&mut self, track_id: SpotifyId) { - let unavailables = self.get_track_index_for_spotify_id(&track_id, 0); - for &index in unavailables.iter() { - debug_assert_eq!(self.state.get_track()[index].get_gid(), track_id.to_raw()); - let mut unplayable_track_ref = TrackRef::new(); - unplayable_track_ref.set_gid(self.state.get_track()[index].get_gid().to_vec()); - // Misuse context field to flag the track - unplayable_track_ref.set_context(String::from("NonPlayable")); - std::mem::swap( - &mut self.state.mut_track()[index], - &mut unplayable_track_ref, - ); - debug!( - "Marked <{:?}> at {:?} as NonPlayable", - self.state.get_track()[index], - index, - ); - } + fn handle_unavailable(&mut self, track_id: &SpotifyUri) -> Result<(), Error> { + self.connect_state.mark_unavailable(track_id)?; self.handle_preload_next_track(); + + Ok(()) } - fn handle_next(&mut self) { - let mut new_index = self.consume_queued_track() as u32; - let mut continue_playing = true; - let tracks_len = self.state.get_track().len() as u32; - debug!( - "At track {:?} of {:?} <{:?}> update [{}]", - new_index + 1, - tracks_len, - self.state.get_context_uri(), - tracks_len - new_index < CONTEXT_FETCH_THRESHOLD - ); - let context_uri = self.state.get_context_uri().to_owned(); - if (context_uri.starts_with("spotify:station:") - || context_uri.starts_with("spotify:dailymix:") - // spotify:user:xxx:collection - || context_uri.starts_with(&format!("spotify:user:{}:collection",url_encode(&self.session.username())))) - && ((self.state.get_track().len() as u32) - new_index) < CONTEXT_FETCH_THRESHOLD - { - self.context_fut = self.resolve_station(&context_uri); - self.update_tracks_from_context(); - } - if new_index >= tracks_len { - 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 - ); - } + fn add_autoplay_resolving_when_required(&mut self) { + let require_load_new = !self + .connect_state + .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD)) + && self.session.autoplay() + && !self.connect_state.context_uri().is_empty(); + + if !require_load_new { + return; } - if tracks_len > 0 { - self.state.set_playing_track_index(new_index); - self.load_track(continue_playing, 0); + let current_context = self.connect_state.context_uri(); + let fallback = self.connect_state.current_track(|t| &t.uri); + + let has_tracks = self + .connect_state + .get_context(ContextType::Autoplay) + .map(|c| !c.tracks.is_empty()) + .unwrap_or_default(); + + let resolve = ResolveContext::from_uri( + current_context, + fallback, + ContextType::Autoplay, + if has_tracks { + ContextAction::Append + } else { + ContextAction::Replace + }, + ); + + self.context_resolver.add(resolve); + } + + fn handle_next(&mut self, track_uri: Option) -> Result<(), Error> { + let continue_playing = self.connect_state.is_playing(); + + let current_uri = self.connect_state.current_track(|t| &t.uri); + let mut has_next_track = + matches!(track_uri, Some(ref track_uri) if current_uri == track_uri); + + if !has_next_track { + has_next_track = loop { + let index = self.connect_state.next_track()?; + + let current_uri = self.connect_state.current_track(|t| &t.uri); + if matches!(track_uri, Some(ref track_uri) if current_uri != track_uri) { + continue; + } else { + break index.is_some(); + } + }; + }; + + if has_next_track { + self.add_autoplay_resolving_when_required(); + self.load_track(continue_playing, 0) } else { info!("Not playing next track because there are no more tracks left in queue."); - self.state.set_playing_track_index(0); - self.state.set_status(PlayStatus::kPlayStatusStop); - self.player.stop(); - self.play_status = SpircPlayStatus::Stopped; + self.handle_stop(); + Ok(()) } } - fn handle_prev(&mut self) { + fn handle_prev(&mut self) -> Result<(), Error> { // Previous behaves differently based on the position // Under 3s it goes to the previous song (starts playing) // Over 3s it seeks to zero (retains previous play status) if self.position() < 3000 { - // Queued tracks always follow the currently playing track. - // They should not be considered when calculating the previous - // track so extract them beforehand and reinsert them after it. - let mut queue_tracks = Vec::new(); - { - let queue_index = self.consume_queued_track(); - let tracks = self.state.mut_track(); - while queue_index < tracks.len() && tracks[queue_index].get_queued() { - queue_tracks.push(tracks.remove(queue_index)); + let repeat_context = self.connect_state.repeat_context(); + match self.connect_state.prev_track()? { + None if repeat_context => self.connect_state.reset_playback_to_position(None)?, + None => { + self.connect_state.reset_playback_to_position(None)?; + self.handle_stop() } + Some(_) => self.load_track(self.connect_state.is_playing(), 0)?, } - let current_index = self.state.get_playing_track_index(); - let new_index = if current_index > 0 { - current_index - 1 - } else if self.state.get_repeat() { - self.state.get_track().len() as u32 - 1 - } else { - 0 - }; - // Reinsert queued tracks after the new playing track. - let mut pos = (new_index + 1) as usize; - for track in queue_tracks { - self.state.mut_track().insert(pos, track); - pos += 1; - } - - self.state.set_playing_track_index(new_index); - - self.load_track(true, 0); } else { self.handle_seek(0); } + + Ok(()) } fn handle_volume_up(&mut self) { - let volume = (self.device.get_volume() as u16).saturating_add(VOLUME_STEP_SIZE); + let volume = (self.connect_state.device_info().volume as u16) + .saturating_add(self.connect_state.volume_step_size); + self.set_volume(volume); } fn handle_volume_down(&mut self) { - let volume = (self.device.get_volume() as u16).saturating_sub(VOLUME_STEP_SIZE); + let volume = (self.connect_state.device_info().volume as u16) + .saturating_sub(self.connect_state.volume_step_size); + self.set_volume(volume); } - fn handle_end_of_track(&mut self) { - self.handle_next(); - self.notify(None, true); + fn handle_playlist_modification( + &mut self, + playlist_modification_info: PlaylistModificationInfo, + ) -> Result<(), Error> { + let uri = playlist_modification_info + .uri + .ok_or(SpircError::NoUri("playlist modification"))?; + let uri = String::from_utf8(uri)?; + + if self.connect_state.context_uri() != &uri { + debug!( + "ignoring playlist modification update for playlist <{uri}>, because it isn't the current context" + ); + return Ok(()); + } + + debug!("playlist modification for current context: {uri}"); + self.context_resolver.add(ResolveContext::from_uri( + uri, + self.connect_state.current_track(|t| &t.uri), + ContextType::Default, + ContextAction::Replace, + )); + + Ok(()) + } + + fn handle_session_update(&mut self, session_update: FallbackWrapper) { + // we know that this enum value isn't present in our current proto definitions, by that + // the json parsing fails because the enum isn't known as proto representation + const WBC: &str = "WIFI_BROADCAST_CHANGED"; + + let mut session_update = match session_update { + FallbackWrapper::Inner(update) => update, + FallbackWrapper::Fallback(value) => { + let fallback_inner = value.to_string(); + if fallback_inner.contains(WBC) { + log::debug!("Received SessionUpdate::{WBC}"); + } else { + log::warn!("SessionUpdate couldn't be parse correctly: {value:?}"); + } + return; + } + }; + + let reason = session_update.reason.enum_value(); + + let mut session = match session_update.session.take() { + None => return, + Some(session) => session, + }; + + let active_device = session.host_active_device_id.take(); + if matches!(active_device, Some(ref device) if device == self.session.device_id()) { + info!( + "session update: <{:?}> for self, current session_id {}, new session_id {}", + reason, + self.session.session_id(), + session.session_id + ); + + if self.session.session_id() != session.session_id { + self.session.set_session_id(&session.session_id); + self.connect_state.set_session_id(session.session_id); + } + } else { + debug!("session update: <{reason:?}> from active session host: <{active_device:?}>"); + } + + // this seems to be used for jams or handling the current session_id + // + // handling this event was intended to keep the playback when other clients (primarily + // mobile) connects, otherwise they would steel the current playback when there was no + // session_id provided on the initial PutStateReason::NEW_DEVICE state update + // + // by generating an initial session_id from the get-go we prevent that behavior and + // currently don't need to handle this event, might still be useful for later "jam" support } fn position(&mut self) -> u32 { @@ -996,290 +1696,67 @@ impl SpircTask { } } - fn resolve_station(&self, uri: &str) -> BoxedFuture> { - let radio_uri = format!("hm://radio-apollo/v3/stations/{}", uri); + fn load_track(&mut self, start_playing: bool, position_ms: u32) -> Result<(), Error> { + if self.connect_state.current_track(MessageField::is_none) { + debug!("current track is none, stopping playback"); + self.handle_stop(); + return Ok(()); + } - self.resolve_uri(&radio_uri) - } + let current_uri = self.connect_state.current_track(|t| &t.uri); + let id = SpotifyUri::from_uri(current_uri)?; + self.player.load(id, start_playing, position_ms); - 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?; - - 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) - } else { - warn!("No autoplay_uri found"); - Err(MercuryError) - } - } - .fuse(), - ) - } - - fn resolve_uri(&self, uri: &str) -> BoxedFuture> { - let request = self.session.mercury().get(uri); - - Box::pin( - async move { - 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(); - - Ok(response) - } - .fuse(), - ) - } - - fn update_tracks_from_context(&mut self) { - if let Some(ref context) = self.context { - self.context_fut = self.resolve_uri(&context.next_page_url); - - let new_tracks = &context.tracks; - debug!("Adding {:?} tracks from context to frame", new_tracks.len()); - let mut track_vec = self.state.take_track().into_vec(); - if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) { - track_vec.drain(0..head); - } - track_vec.extend_from_slice(new_tracks); - self.state - .set_track(protobuf::RepeatedField::from_vec(track_vec)); - - // Update playing index - if let Some(new_index) = self - .state - .get_playing_track_index() - .checked_sub(CONTEXT_TRACKS_HISTORY as u32) - { - self.state.set_playing_track_index(new_index); - } + self.connect_state + .update_position(position_ms, self.now_ms()); + if start_playing { + self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; } else { - warn!("No context to update from!"); + self.play_status = SpircPlayStatus::LoadingPause { position_ms }; } + self.connect_state.set_status(&self.play_status); + + Ok(()) } - fn update_tracks(&mut self, frame: &protocol::spirc::Frame) { - debug!("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()); - if context_uri.starts_with("spotify:station:") - || context_uri.starts_with("spotify:dailymix:") - { - self.context_fut = self.resolve_station(&context_uri); - } else if self.config.autoplay { - info!("Fetching autoplay context uri"); - // Get autoplay_station_uri for regular playlists - self.autoplay_fut = self.resolve_autoplay_uri(&context_uri); + async fn notify(&mut self) -> Result<(), Error> { + self.connect_state.set_status(&self.play_status); + + if self.connect_state.is_playing() { + self.connect_state + .update_position_in_relation(self.now_ms()); } - self.player - .set_auto_normalise_as_album(context_uri.starts_with("spotify:album:")); + self.connect_state.set_now(self.now_ms() as u64); - self.state.set_playing_track_index(index); - self.state.set_track(tracks.iter().cloned().collect()); - self.state.set_context_uri(context_uri); - // has_shuffle/repeat seem to always be true in these replace msgs, - // but to replicate the behaviour of the Android client we have to - // ignore false values. - let state = frame.get_state(); - if state.get_repeat() { - self.state.set_repeat(true); - } - if state.get_shuffle() { - self.state.set_shuffle(true); - } - } - - // 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, - track_id: &SpotifyId, - start_index: usize, - ) -> Vec { - let index: Vec = self.state.get_track()[start_index..] - .iter() - .enumerate() - .filter(|&(_, track_ref)| track_ref.get_gid() == track_id.to_raw()) - .map(|(idx, _)| start_index + idx) - .collect(); - // Sanity check - debug_assert!(!index.is_empty()); - index - } - - // Broken out here so we can refactor this later when we move to SpotifyObjectID or similar - fn track_ref_is_unavailable(&self, track_ref: &TrackRef) -> bool { - track_ref.get_context() == "NonPlayable" - } - - fn get_track_id_to_play_from_playlist(&self, index: u32) -> Option<(SpotifyId, u32)> { - let tracks_len = self.state.get_track().len(); - - let mut new_playlist_index = index as usize; - - if new_playlist_index >= tracks_len { - new_playlist_index = 0; - } - - let start_index = new_playlist_index; - - // Cycle through all tracks, break if we don't find any playable tracks - // tracks in each frame either have a gid or uri (that may or may not be a valid track) - // 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 - { - warn!( - "Skipping track <{:?}> at position [{}] of {}", - track_ref, new_playlist_index, tracks_len - ); - - new_playlist_index += 1; - if new_playlist_index >= tracks_len { - new_playlist_index = 0; - } - - if new_playlist_index == start_index { - warn!("No playable track found in state: {:?}", self.state); - return None; - } - track_ref = self.state.get_track()[new_playlist_index].clone(); - track_id = self.get_spotify_id_for_track(&track_ref); - } - - match track_id { - Ok(track_id) => Some((track_id, new_playlist_index as u32)), - Err(_) => None, - } - } - - fn load_track(&mut self, start_playing: bool, position_ms: u32) { - let index = self.state.get_playing_track_index(); - - match self.get_track_id_to_play_from_playlist(index) { - Some((track, index)) => { - self.state.set_playing_track_index(index); - - self.play_request_id = Some(self.player.load(track, start_playing, position_ms)); - - self.update_state_position(position_ms); - if start_playing { - self.state.set_status(PlayStatus::kPlayStatusPlay); - self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; - } else { - self.state.set_status(PlayStatus::kPlayStatusPause); - self.play_status = SpircPlayStatus::LoadingPause { position_ms }; - } - } - None => { - self.state.set_status(PlayStatus::kPlayStatusStop); - self.player.stop(); - self.play_status = SpircPlayStatus::Stopped; - } - } - } - - fn hello(&mut self) { - CommandSender::new(self, MessageType::kMessageTypeHello).send(); - } - - fn notify(&mut self, recipient: Option<&str>, suppress_loading_status: bool) { - if suppress_loading_status && (self.state.get_status() == PlayStatus::kPlayStatusLoading) { - return; - }; - let status_string = match self.state.get_status() { - PlayStatus::kPlayStatusLoading => "kPlayStatusLoading", - PlayStatus::kPlayStatusPause => "kPlayStatusPause", - PlayStatus::kPlayStatusStop => "kPlayStatusStop", - PlayStatus::kPlayStatusPlay => "kPlayStatusPlay", - }; - 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.send(); + self.connect_state + .send_state(&self.session) + .await + .map(|_| ()) } fn set_volume(&mut self, volume: u16) { - self.device.set_volume(volume as u32); - self.mixer.set_volume(volume); - if let Some(cache) = self.session.cache() { - cache.save_volume(volume) + debug!("SpircTask::set_volume({volume})"); + + let old_volume = self.connect_state.device_info().volume; + let new_volume = volume as u32; + if old_volume != new_volume || self.mixer.volume() != volume { + self.update_volume = true; + + self.connect_state.set_volume(new_volume); + self.mixer.set_volume(volume); + if let Some(cache) = self.session.cache() { + cache.save_volume(volume) + } + if self.connect_state.is_active() { + self.player.emit_volume_changed_event(volume); + } } - self.player.emit_volume_set_event(volume); } } impl Drop for SpircTask { fn drop(&mut self) { - debug!("drop Spirc[{}]", self.session.session_id()); - } -} - -struct CommandSender<'a> { - spirc: &'a mut SpircTask, - frame: protocol::spirc::Frame, -} - -impl<'a> CommandSender<'a> { - fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> CommandSender { - let mut frame = protocol::spirc::Frame::new(); - frame.set_version(1); - frame.set_protocol_version(::std::convert::Into::into("2.0.0")); - frame.set_ident(spirc.ident.clone()); - frame.set_seq_nr(spirc.sequence.get()); - frame.set_typ(cmd); - frame.set_device_state(spirc.device.clone()); - frame.set_state_update_id(spirc.now_ms()); - CommandSender { spirc, frame } - } - - fn recipient(mut self, recipient: &'a str) -> CommandSender { - self.frame.mut_recipient().push(recipient.to_owned()); - self - } - - #[allow(dead_code)] - fn state(mut self, state: protocol::spirc::State) -> CommandSender<'a> { - self.frame.set_state(state); - self - } - - fn send(mut self) { - 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()); + debug!("drop Spirc[{}]", self.spirc_id); } } diff --git a/connect/src/state.rs b/connect/src/state.rs new file mode 100644 index 00000000..a84de234 --- /dev/null +++ b/connect/src/state.rs @@ -0,0 +1,495 @@ +pub(super) mod context; +mod handle; +mod metadata; +mod options; +pub(super) mod provider; +mod restrictions; +mod tracks; +mod transfer; + +use crate::{ + core::{ + Error, Session, config::DeviceType, date::Date, dealer::protocol::Request, + spclient::SpClientResult, version, + }, + model::SpircPlayStatus, + protocol::{ + connect::{Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest}, + media::AudioQuality, + player::{ + ContextIndex, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack, + Suppressions, + }, + }, + state::{ + context::{ContextType, ResetContext, StateContext}, + options::ShuffleState, + provider::{IsProvider, Provider}, + }, +}; +use log::LevelFilter; +use protobuf::{EnumOrUnknown, MessageField}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; + +// these limitations are essential, otherwise to many tracks will overload the web-player +const SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10; +const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80; + +#[derive(Debug, Error)] +pub(super) enum StateError { + #[error("the current track couldn't be resolved from the transfer state")] + CouldNotResolveTrackFromTransfer, + #[error("context is not available. type: {0:?}")] + NoContext(ContextType), + #[error("could not find track {0:?} in context of {1}")] + CanNotFindTrackInContext(Option, usize), + #[error("currently {action} is not allowed because {reason}")] + CurrentlyDisallowed { + action: &'static str, + reason: String, + }, + #[error("the provided context has no tracks")] + ContextHasNoTracks, + #[error("playback of local files is not supported")] + UnsupportedLocalPlayback, + #[error("track uri <{0:?}> contains invalid characters")] + InvalidTrackUri(Option), +} + +impl From for Error { + fn from(err: StateError) -> Self { + use StateError::*; + match err { + CouldNotResolveTrackFromTransfer + | NoContext(_) + | CanNotFindTrackInContext(_, _) + | ContextHasNoTracks + | InvalidTrackUri(_) => Error::failed_precondition(err), + CurrentlyDisallowed { .. } | UnsupportedLocalPlayback => Error::unavailable(err), + } + } +} + +/// Configuration of the connect device +#[derive(Debug, Clone)] +pub struct ConnectConfig { + /// The name of the connect device (default: librespot) + pub name: String, + /// The icon type of the connect device (default: [DeviceType::Speaker]) + pub device_type: DeviceType, + /// Displays the [DeviceType] twice in the ui to show up as a group (default: false) + pub is_group: bool, + /// The volume with which the connect device will be initialized (default: 50%) + pub initial_volume: u16, + /// Disables the option to control the volume remotely (default: false) + pub disable_volume: bool, + /// Number of incremental steps (default: 64) + pub volume_steps: u16, +} + +impl Default for ConnectConfig { + fn default() -> Self { + Self { + name: "librespot".to_string(), + device_type: DeviceType::Speaker, + is_group: false, + initial_volume: u16::MAX / 2, + disable_volume: false, + volume_steps: 64, + } + } +} + +#[derive(Default, Debug)] +pub(super) struct ConnectState { + /// the entire state that is updated to the remote server + request: PutStateRequest, + + unavailable_uri: Vec, + + active_since: Option, + queue_count: u64, + + // separation is necessary because we could have already loaded + // the autoplay context but are still playing from the default context + /// to update the active context use [switch_active_context](ConnectState::set_active_context) + pub active_context: ContextType, + fill_up_context: ContextType, + + /// the context from which we play, is used to top up prev and next tracks + context: Option, + /// seed extracted in [ConnectState::handle_initial_transfer] and used in [ConnectState::finish_transfer] + transfer_shuffle: Option, + + /// a context to keep track of the autoplay context + autoplay_context: Option, + + /// The volume adjustment per step when handling individual volume adjustments. + pub volume_step_size: u16, +} + +impl ConnectState { + pub fn new(cfg: ConnectConfig, session: &Session) -> Self { + let volume_step_size = u16::MAX.checked_div(cfg.volume_steps).unwrap_or(1024); + + let device_info = DeviceInfo { + can_play: true, + volume: cfg.initial_volume.into(), + name: cfg.name, + device_id: session.device_id().to_string(), + device_type: EnumOrUnknown::new(cfg.device_type.into()), + device_software_version: version::SEMVER.to_string(), + spirc_version: version::SPOTIFY_SPIRC_VERSION.to_string(), + client_id: session.client_id(), + is_group: cfg.is_group, + capabilities: MessageField::some(Capabilities { + volume_steps: cfg.volume_steps.into(), + disable_volume: cfg.disable_volume, + + gaia_eq_connect_id: true, + can_be_player: true, + needs_full_player_state: true, + is_observable: true, + is_controllable: true, + hidden: false, + + supports_gzip_pushes: true, + // todo: enable after logout handling is implemented, see spirc logout_request + supports_logout: false, + supported_types: vec!["audio/episode".into(), "audio/track".into()], + supports_playlist_v2: true, + supports_transfer_command: true, + supports_command_request: true, + supports_set_options_command: true, + + is_voice_enabled: false, + restrict_to_local: false, + connect_disabled: false, + supports_rename: false, + supports_external_episodes: false, + supports_set_backend_metadata: false, + supports_hifi: MessageField::none(), + // that "AI" dj thingy only available to specific regions/users + supports_dj: false, + supports_rooms: false, + // AudioQuality::HIFI is available, further investigation necessary + supported_audio_quality: EnumOrUnknown::new(AudioQuality::VERY_HIGH), + + command_acks: true, + + ..Default::default() + }), + ..Default::default() + }; + + let mut state = Self { + request: PutStateRequest { + member_type: EnumOrUnknown::new(MemberType::CONNECT_STATE), + put_state_reason: EnumOrUnknown::new(PutStateReason::PLAYER_STATE_CHANGED), + device: MessageField::some(Device { + device_info: MessageField::some(device_info), + player_state: MessageField::some(PlayerState { + session_id: session.session_id(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, + volume_step_size, + ..Default::default() + }; + state.reset(); + state + } + + fn reset(&mut self) { + self.set_active(false); + self.queue_count = 0; + + // preserve the session_id + let session_id = self.player().session_id.clone(); + + self.device_mut().player_state = MessageField::some(PlayerState { + session_id, + is_system_initiated: true, + playback_speed: 1., + play_origin: MessageField::some(PlayOrigin::new()), + suppressions: MessageField::some(Suppressions::new()), + options: MessageField::some(ContextPlayerOptions::new()), + // + 1, so that we have a buffer where we can swap elements + prev_tracks: Vec::with_capacity(SPOTIFY_MAX_PREV_TRACKS_SIZE + 1), + next_tracks: Vec::with_capacity(SPOTIFY_MAX_NEXT_TRACKS_SIZE + 1), + ..Default::default() + }); + } + + fn device_mut(&mut self) -> &mut Device { + self.request + .device + .as_mut() + .expect("the request is always available") + } + + fn player_mut(&mut self) -> &mut PlayerState { + self.device_mut() + .player_state + .as_mut() + .expect("the player_state has to be always given") + } + + pub fn device_info(&self) -> &DeviceInfo { + &self.request.device.device_info + } + + pub fn player(&self) -> &PlayerState { + &self.request.device.player_state + } + + pub fn is_active(&self) -> bool { + self.request.is_active + } + + /// Returns the `is_playing` value as perceived by other connect devices + /// + /// see [ConnectState::set_status] + pub fn is_playing(&self) -> bool { + let player = self.player(); + player.is_playing && !player.is_paused + } + + /// Returns the `is_paused` state value as perceived by other connect devices + /// + /// see [ConnectState::set_status] + pub fn is_pause(&self) -> bool { + let player = self.player(); + player.is_playing && player.is_paused && player.is_buffering + } + + pub fn set_volume(&mut self, volume: u32) { + self.device_mut() + .device_info + .as_mut() + .expect("the device_info has to be always given") + .volume = volume; + } + + pub fn set_last_command(&mut self, command: Request) { + self.request.last_command_message_id = command.message_id; + self.request.last_command_sent_by_device_id = command.sent_by_device_id; + } + + pub fn set_now(&mut self, now: u64) { + self.request.client_side_timestamp = now; + + if let Some(active_since) = self.active_since { + if let Ok(active_since_duration) = active_since.duration_since(UNIX_EPOCH) { + match active_since_duration.as_millis().try_into() { + Ok(active_since_ms) => self.request.started_playing_at = active_since_ms, + Err(why) => warn!("couldn't update active since because {why}"), + } + } + } + } + + pub fn set_active(&mut self, value: bool) { + if value { + if self.request.is_active { + return; + } + + self.request.is_active = true; + self.active_since = Some(SystemTime::now()) + } else { + self.request.is_active = false; + self.active_since = None + } + } + + pub fn set_origin(&mut self, origin: PlayOrigin) { + self.player_mut().play_origin = MessageField::some(origin) + } + + pub fn set_session_id(&mut self, session_id: String) { + self.player_mut().session_id = session_id; + } + + pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) { + let player = self.player_mut(); + player.is_paused = matches!( + status, + SpircPlayStatus::LoadingPause { .. } + | SpircPlayStatus::Paused { .. } + | SpircPlayStatus::Stopped + ); + + if player.is_paused { + player.playback_speed = 0.; + } else { + player.playback_speed = 1.; + } + + // desktop and mobile require all 'states' set to true, when we are paused, + // otherwise the play button (desktop) is grayed out or the preview (mobile) can't be opened + player.is_buffering = player.is_paused + || matches!( + status, + SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. } + ); + player.is_playing = player.is_paused + || matches!( + status, + SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. } + ); + + debug!( + "updated connect play status playing: {}, paused: {}, buffering: {}", + player.is_playing, player.is_paused, player.is_buffering + ); + + self.update_restrictions() + } + + /// index is 0 based, so the first track is index 0 + pub fn update_current_index(&mut self, f: impl Fn(&mut ContextIndex)) { + match self.player_mut().index.as_mut() { + Some(player_index) => f(player_index), + None => { + let mut new_index = ContextIndex::new(); + f(&mut new_index); + self.player_mut().index = MessageField::some(new_index) + } + } + } + + pub fn update_position(&mut self, position_ms: u32, timestamp: i64) { + let player = self.player_mut(); + player.position_as_of_timestamp = position_ms.into(); + player.timestamp = timestamp; + } + + pub fn update_duration(&mut self, duration: u32) { + self.player_mut().duration = duration.into() + } + + pub fn update_queue_revision(&mut self) { + let mut state = DefaultHasher::new(); + self.next_tracks() + .iter() + .for_each(|t| t.uri.hash(&mut state)); + self.player_mut().queue_revision = state.finish().to_string() + } + + pub fn reset_playback_to_position(&mut self, new_index: Option) -> Result<(), Error> { + debug!( + "reset_playback with active ctx <{:?}> fill_up ctx <{:?}>", + self.active_context, self.fill_up_context + ); + + let new_index = new_index.unwrap_or(0); + self.update_current_index(|i| i.track = new_index as u32); + self.update_context_index(self.active_context, new_index + 1)?; + self.fill_up_context = self.active_context; + + if !self.current_track(|t| t.is_queue() || self.is_skip_track(t, None)) { + self.set_current_track(new_index)?; + } + + self.clear_prev_track(); + + if new_index > 0 { + let context = self.get_context(self.active_context)?; + + let before_new_track = context.tracks.len() - new_index; + self.player_mut().prev_tracks = context + .tracks + .iter() + .rev() + .skip(before_new_track) + .take(SPOTIFY_MAX_PREV_TRACKS_SIZE) + .rev() + .cloned() + .collect(); + debug!("has {} prev tracks", self.prev_tracks().len()) + } + + self.clear_next_tracks(); + self.fill_up_next_tracks()?; + self.update_restrictions(); + + Ok(()) + } + + fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, uri: &str) { + if track.uri == uri { + debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); + track.set_provider(Provider::Unavailable); + } + } + + pub fn update_position_in_relation(&mut self, timestamp: i64) { + let player = self.player_mut(); + + let diff = timestamp - player.timestamp; + player.position_as_of_timestamp += diff; + + if log::max_level() >= LevelFilter::Debug { + let pos = Duration::from_millis(player.position_as_of_timestamp as u64); + let time = Date::from_timestamp_ms(timestamp) + .map(|d| d.time().to_string()) + .unwrap_or_else(|_| timestamp.to_string()); + + let sec = pos.as_secs(); + let (min, sec) = (sec / 60, sec % 60); + debug!("update position to {min}:{sec:0>2} at {time}"); + } + + player.timestamp = timestamp; + } + + pub async fn became_inactive(&mut self, session: &Session) -> SpClientResult { + self.reset(); + self.reset_context(ResetContext::Completely); + + session.spclient().put_connect_state_inactive(false).await + } + + async fn send_with_reason( + &mut self, + session: &Session, + reason: PutStateReason, + ) -> SpClientResult { + let prev_reason = self.request.put_state_reason; + + self.request.put_state_reason = EnumOrUnknown::new(reason); + let res = self.send_state(session).await; + + self.request.put_state_reason = prev_reason; + res + } + + /// Notifies the remote server about a new device + pub async fn notify_new_device_appeared(&mut self, session: &Session) -> SpClientResult { + self.send_with_reason(session, PutStateReason::NEW_DEVICE) + .await + } + + /// Notifies the remote server about a new volume + pub async fn notify_volume_changed(&mut self, session: &Session) -> SpClientResult { + self.send_with_reason(session, PutStateReason::VOLUME_CHANGED) + .await + } + + /// Sends the connect state for the connect session to the remote server + pub async fn send_state(&self, session: &Session) -> SpClientResult { + session + .spclient() + .put_connect_state_request(&self.request) + .await + } +} diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs new file mode 100644 index 00000000..e2b78720 --- /dev/null +++ b/connect/src/state/context.rs @@ -0,0 +1,520 @@ +use crate::{ + core::{Error, SpotifyId, SpotifyUri}, + protocol::{ + context::Context, + context_page::ContextPage, + context_track::ContextTrack, + player::{ContextIndex, ProvidedTrack}, + restrictions::Restrictions, + }, + shuffle_vec::ShuffleVec, + state::{ + ConnectState, SPOTIFY_MAX_NEXT_TRACKS_SIZE, StateError, + metadata::Metadata, + provider::{IsProvider, Provider}, + }, +}; +use protobuf::MessageField; +use std::collections::HashMap; +use uuid::Uuid; + +const LOCAL_FILES_IDENTIFIER: &str = "spotify:local-files"; +const SEARCH_IDENTIFIER: &str = "spotify:search"; + +#[derive(Debug)] +pub struct StateContext { + pub tracks: ShuffleVec, + pub metadata: HashMap, + pub restrictions: Option, + /// is used to keep track which tracks are already loaded into the next_tracks + pub index: ContextIndex, +} + +#[derive(Default, Debug, Copy, Clone, PartialEq, Hash, Eq)] +pub enum ContextType { + #[default] + Default, + Autoplay, +} + +pub enum ResetContext<'s> { + Completely, + DefaultIndex, + WhenDifferent(&'s str), +} + +/// Extracts the spotify uri from a given page_url +/// +/// Just extracts "spotify/album/5LFzwirfFwBKXJQGfwmiMY" and replaces the slash's with colon's +/// +/// Expected `page_url` should look something like the following: +/// `hm://artistplaycontext/v1/page/spotify/album/5LFzwirfFwBKXJQGfwmiMY/km_artist` +fn page_url_to_uri(page_url: &str) -> String { + let split = if let Some(rest) = page_url.strip_prefix("hm://") { + rest.split('/') + } else { + warn!("page_url didn't start with hm://. got page_url: {page_url}"); + page_url.split('/') + }; + + split + .skip_while(|s| s != &"spotify") + .take(3) + .collect::>() + .join(":") +} + +impl ConnectState { + pub fn find_index_in_context bool>( + ctx: &StateContext, + f: F, + ) -> Result { + ctx.tracks + .iter() + .position(f) + .ok_or(StateError::CanNotFindTrackInContext(None, ctx.tracks.len())) + } + + pub fn get_context(&self, ty: ContextType) -> Result<&StateContext, StateError> { + match ty { + ContextType::Default => self.context.as_ref(), + ContextType::Autoplay => self.autoplay_context.as_ref(), + } + .ok_or(StateError::NoContext(ty)) + } + + pub fn get_context_mut(&mut self, ty: ContextType) -> Result<&mut StateContext, StateError> { + match ty { + ContextType::Default => self.context.as_mut(), + ContextType::Autoplay => self.autoplay_context.as_mut(), + } + .ok_or(StateError::NoContext(ty)) + } + + pub fn context_uri(&self) -> &String { + &self.player().context_uri + } + + fn different_context_uri(&self, uri: &str) -> bool { + // search identifier is always different + self.context_uri() != uri || uri.starts_with(SEARCH_IDENTIFIER) + } + + pub fn reset_context(&mut self, mut reset_as: ResetContext) { + if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.different_context_uri(ctx)) { + reset_as = ResetContext::Completely + } + + if let Ok(ctx) = self.get_context_mut(ContextType::Default) { + ctx.remove_shuffle_seed(); + ctx.remove_initial_track(); + ctx.tracks.unshuffle() + } + + match reset_as { + ResetContext::WhenDifferent(_) => debug!("context didn't change, no reset"), + ResetContext::Completely => { + self.context = None; + self.autoplay_context = None; + + let player = self.player_mut(); + player.context_uri.clear(); + player.context_url.clear(); + } + ResetContext::DefaultIndex => { + for ctx in [self.context.as_mut(), self.autoplay_context.as_mut()] + .into_iter() + .flatten() + { + ctx.index.track = 0; + ctx.index.page = 0; + } + } + } + + self.fill_up_context = ContextType::Default; + self.set_active_context(ContextType::Default); + self.update_restrictions() + } + + pub fn valid_resolve_uri(uri: &str) -> Option<&str> { + if uri.is_empty() || uri.starts_with(SEARCH_IDENTIFIER) { + None + } else { + Some(uri) + } + } + + pub fn find_valid_uri<'s>( + context_uri: Option<&'s str>, + first_page: Option<&'s ContextPage>, + ) -> Option<&'s str> { + context_uri + .and_then(Self::valid_resolve_uri) + .or_else(|| first_page.and_then(|p| p.tracks.first().and_then(|t| t.uri.as_deref()))) + } + + pub fn set_active_context(&mut self, new_context: ContextType) { + self.active_context = new_context; + + let player = self.player_mut(); + + player.context_metadata = Default::default(); + player.context_restrictions = MessageField::some(Default::default()); + player.restrictions = MessageField::some(Default::default()); + + let ctx = match self.get_context(new_context) { + Err(why) => { + warn!("couldn't load context info because: {why}"); + return; + } + Ok(ctx) => ctx, + }; + + let mut restrictions = ctx.restrictions.clone(); + let metadata = ctx.metadata.clone(); + + let player = self.player_mut(); + + if let Some(restrictions) = restrictions.take() { + player.restrictions = MessageField::some(restrictions.into()); + } + + for (key, value) in metadata { + player.context_metadata.insert(key, value); + } + } + + pub fn update_context( + &mut self, + mut context: Context, + ty: ContextType, + ) -> Result>, Error> { + if context.pages.iter().all(|p| p.tracks.is_empty()) { + error!("context didn't have any tracks: {context:#?}"); + Err(StateError::ContextHasNoTracks)?; + } else if matches!(context.uri, Some(ref uri) if uri.starts_with(LOCAL_FILES_IDENTIFIER)) { + Err(StateError::UnsupportedLocalPlayback)?; + } + + let mut next_contexts = Vec::new(); + let mut first_page = None; + for page in context.pages { + if first_page.is_none() && !page.tracks.is_empty() { + first_page = Some(page); + } else { + next_contexts.push(page) + } + } + + let page = match first_page { + None => Err(StateError::ContextHasNoTracks)?, + Some(p) => p, + }; + + debug!( + "updated context {ty:?} to <{:?}> ({} tracks)", + context.uri, + page.tracks.len() + ); + + match ty { + ContextType::Default => { + let mut new_context = self.state_context_from_page( + page, + context.metadata, + context.restrictions.take(), + context.uri.as_deref(), + Some(0), + None, + ); + + // when we update the same context, we should try to preserve the previous position + // otherwise we might load the entire context twice, unless it's the search context + if !self.context_uri().starts_with(SEARCH_IDENTIFIER) + && matches!(context.uri, Some(ref uri) if uri == self.context_uri()) + { + if let Some(new_index) = self.find_last_index_in_new_context(&new_context) { + new_context.index.track = match new_index { + Ok(i) => i, + Err(i) => { + self.player_mut().index = MessageField::none(); + i + } + }; + + // enforce reloading the context + if let Ok(autoplay_ctx) = self.get_context_mut(ContextType::Autoplay) { + autoplay_ctx.index.track = 0 + } + self.clear_next_tracks(); + } + } + + self.context = Some(new_context); + + if !matches!(context.url, Some(ref url) if url.contains(SEARCH_IDENTIFIER)) { + self.player_mut().context_url = context.url.take().unwrap_or_default(); + } else { + self.player_mut().context_url.clear() + } + self.player_mut().context_uri = context.uri.take().unwrap_or_default(); + } + ContextType::Autoplay => { + self.autoplay_context = Some(self.state_context_from_page( + page, + context.metadata, + context.restrictions.take(), + context.uri.as_deref(), + None, + Some(Provider::Autoplay), + )) + } + } + + if next_contexts.is_empty() { + return Ok(None); + } + + // load remaining contexts + let next_contexts = next_contexts + .into_iter() + .flat_map(|page| { + if !page.tracks.is_empty() { + self.fill_context_from_page(page).ok()?; + None + } else if matches!(page.page_url, Some(ref url) if !url.is_empty()) { + Some(page_url_to_uri( + &page.page_url.expect("checked by precondition"), + )) + } else { + warn!("unhandled context page: {page:#?}"); + None + } + }) + .collect(); + + Ok(Some(next_contexts)) + } + + fn find_first_prev_track_index(&self, ctx: &StateContext) -> Option { + let prev_tracks = self.prev_tracks(); + for i in (0..prev_tracks.len()).rev() { + let prev_track = prev_tracks.get(i)?; + if let Ok(idx) = Self::find_index_in_context(ctx, |t| prev_track.uri == t.uri) { + return Some(idx); + } + } + None + } + + fn find_last_index_in_new_context( + &self, + new_context: &StateContext, + ) -> Option> { + let ctx = self.context.as_ref()?; + + let is_queued_item = self.current_track(|t| t.is_queue() || t.is_from_queue()); + + let new_index = if ctx.index.track as usize >= SPOTIFY_MAX_NEXT_TRACKS_SIZE { + Some(ctx.index.track as usize - SPOTIFY_MAX_NEXT_TRACKS_SIZE) + } else if is_queued_item { + self.find_first_prev_track_index(new_context) + } else { + Self::find_index_in_context(new_context, |current| { + self.current_track(|t| t.uri == current.uri) + }) + .ok() + } + .map(|i| i as u32 + 1); + + Some(new_index.ok_or_else(|| { + info!( + "couldn't distinguish index from current or previous tracks in the updated context" + ); + let fallback_index = self + .player() + .index + .as_ref() + .map(|i| i.track) + .unwrap_or_default(); + info!("falling back to index {fallback_index}"); + fallback_index + })) + } + + fn state_context_from_page( + &mut self, + page: ContextPage, + metadata: HashMap, + restrictions: Option, + new_context_uri: Option<&str>, + context_length: Option, + provider: Option, + ) -> StateContext { + let new_context_uri = new_context_uri.unwrap_or(self.context_uri()); + + let tracks = page + .tracks + .iter() + .enumerate() + .flat_map(|(i, track)| { + match self.context_to_provided_track( + track, + Some(new_context_uri), + context_length.map(|l| l + i), + Some(&page.metadata), + provider.clone(), + ) { + Ok(t) => Some(t), + Err(why) => { + error!("couldn't convert {track:#?} into ProvidedTrack: {why}"); + None + } + } + }) + .collect::>(); + + StateContext { + tracks: tracks.into(), + restrictions, + metadata, + index: ContextIndex::new(), + } + } + + pub fn is_skip_track(&self, track: &ProvidedTrack, iteration: Option) -> bool { + let ctx = match self.get_context(self.active_context).ok() { + None => return false, + Some(ctx) => ctx, + }; + + if ctx.get_initial_track().is_none_or(|uri| uri != &track.uri) { + return false; + } + + iteration.is_none_or(|i| i == 0) + } + + pub fn merge_context(&mut self, new_page: Option) -> Option<()> { + let current_context = self.get_context_mut(ContextType::Default).ok()?; + + for new_track in new_page?.tracks { + if new_track.uri.is_none() || matches!(new_track.uri, Some(ref uri) if uri.is_empty()) { + continue; + } + + let new_track_uri = new_track.uri.unwrap_or_default(); + if let Ok(position) = + Self::find_index_in_context(current_context, |t| t.uri == new_track_uri) + { + let context_track = current_context.tracks.get_mut(position)?; + + for (key, value) in new_track.metadata { + context_track.metadata.insert(key, value); + } + + // the uid provided from another context might be actual uid of an item + if new_track.uid.is_some() + || matches!(new_track.uid, Some(ref uid) if uid.is_empty()) + { + context_track.uid = new_track.uid.unwrap_or_default(); + } + } + } + + Some(()) + } + + pub(super) fn update_context_index( + &mut self, + ty: ContextType, + new_index: usize, + ) -> Result<(), StateError> { + let context = self.get_context_mut(ty)?; + + context.index.track = new_index as u32; + Ok(()) + } + + pub fn context_to_provided_track( + &self, + ctx_track: &ContextTrack, + context_uri: Option<&str>, + context_index: Option, + page_metadata: Option<&HashMap>, + provider: Option, + ) -> Result { + let id = match (ctx_track.uri.as_ref(), ctx_track.gid.as_ref()) { + (Some(uri), _) if uri.contains(['?', '%']) => { + Err(StateError::InvalidTrackUri(Some(uri.clone())))? + } + (Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?, + (_, Some(gid)) if !gid.is_empty() => SpotifyUri::Track { + id: SpotifyId::from_raw(gid)?, + }, + _ => Err(StateError::InvalidTrackUri(None))?, + }; + + let uri = id.to_uri()?.replace("unknown", "track"); + + let provider = if self.unavailable_uri.contains(&uri) { + Provider::Unavailable + } else { + provider.unwrap_or(Provider::Context) + }; + + // assumption: the uid is used as unique-id of any item + // - queue resorting is done by each client and orients itself by the given uid + // - if no uid is present, resorting doesn't work or behaves not as intended + let uid = match ctx_track.uid.as_ref() { + Some(uid) if !uid.is_empty() => uid.to_string(), + // so providing a unique id should allow to resort the queue + _ => Uuid::new_v4().as_simple().to_string(), + }; + + let mut metadata = page_metadata.cloned().unwrap_or_default(); + for (k, v) in &ctx_track.metadata { + metadata.insert(k.to_string(), v.to_string()); + } + + let mut track = ProvidedTrack { + uri, + uid, + metadata, + provider: provider.to_string(), + ..Default::default() + }; + + if let Some(context_uri) = context_uri { + track.set_entity_uri(context_uri); + track.set_context_uri(context_uri); + } + + if let Some(index) = context_index { + track.set_context_index(index); + } + + if matches!(provider, Provider::Autoplay) { + track.set_from_autoplay(true) + } + + Ok(track) + } + + pub fn fill_context_from_page(&mut self, page: ContextPage) -> Result<(), Error> { + let ctx_len = self.context.as_ref().map(|c| c.tracks.len()); + let context = self.state_context_from_page(page, HashMap::new(), None, None, ctx_len, None); + + let ctx = self + .context + .as_mut() + .ok_or(StateError::NoContext(ContextType::Default))?; + + for t in context.tracks { + ctx.tracks.push(t) + } + + Ok(()) + } +} diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs new file mode 100644 index 00000000..e031410a --- /dev/null +++ b/connect/src/state/handle.rs @@ -0,0 +1,57 @@ +use crate::{ + core::{Error, dealer::protocol::SetQueueCommand}, + state::{ + ConnectState, + context::{ContextType, ResetContext}, + metadata::Metadata, + }, +}; +use protobuf::MessageField; + +impl ConnectState { + pub fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> { + self.set_shuffle(shuffle); + + if shuffle { + return self.shuffle_new(); + } + + self.reset_context(ResetContext::DefaultIndex); + + if self.current_track(MessageField::is_none) { + return Ok(()); + } + + match self.current_track(|t| t.get_context_index()) { + Some(current_index) => self.reset_playback_to_position(Some(current_index)), + None => { + let ctx = self.get_context(ContextType::Default)?; + let current_index = ConnectState::find_index_in_context(ctx, |c| { + self.current_track(|t| c.uri == t.uri) + })?; + self.reset_playback_to_position(Some(current_index)) + } + } + } + + pub fn handle_set_queue(&mut self, set_queue: SetQueueCommand) { + self.set_next_tracks(set_queue.next_tracks); + self.set_prev_tracks(set_queue.prev_tracks); + self.update_queue_revision(); + } + + pub fn handle_set_repeat_context(&mut self, repeat: bool) -> Result<(), Error> { + self.set_repeat_context(repeat); + + if repeat { + if let ContextType::Autoplay = self.fill_up_context { + self.fill_up_context = ContextType::Default; + } + } + + let ctx = self.get_context(ContextType::Default)?; + let current_track = + ConnectState::find_index_in_context(ctx, |t| self.current_track(|t| &t.uri) == &t.uri)?; + self.reset_playback_to_position(Some(current_track)) + } +} diff --git a/connect/src/state/metadata.rs b/connect/src/state/metadata.rs new file mode 100644 index 00000000..82318dab --- /dev/null +++ b/connect/src/state/metadata.rs @@ -0,0 +1,87 @@ +use crate::{ + protocol::{context::Context, context_track::ContextTrack, player::ProvidedTrack}, + state::context::StateContext, +}; +use std::collections::HashMap; +use std::fmt::Display; + +const CONTEXT_URI: &str = "context_uri"; +const ENTITY_URI: &str = "entity_uri"; +const IS_QUEUED: &str = "is_queued"; +const IS_AUTOPLAY: &str = "autoplay.is_autoplay"; +const HIDDEN: &str = "hidden"; +const ITERATION: &str = "iteration"; + +const CUSTOM_CONTEXT_INDEX: &str = "context_index"; +const CUSTOM_SHUFFLE_SEED: &str = "shuffle_seed"; +const CUSTOM_INITIAL_TRACK: &str = "initial_track"; + +macro_rules! metadata_entry { + ( $get:ident, $set:ident, $clear:ident ($key:ident: $entry:ident)) => { + metadata_entry!( $get use get, $set, $clear ($key: $entry) -> Option<&String> ); + }; + ( $get_key:ident use $get:ident, $set:ident, $clear:ident ($key:ident: $entry:ident) -> $ty:ty ) => { + fn $get_key (&self) -> $ty { + self.$get($entry) + } + + + fn $set (&mut self, $key: impl Display) { + self.metadata_mut().insert($entry.to_string(), $key.to_string()); + } + + fn $clear(&mut self) { + self.metadata_mut().remove($entry); + } + }; +} + +/// Allows easy access of known metadata fields +#[allow(dead_code)] +pub(super) trait Metadata { + fn metadata(&self) -> &HashMap; + + fn metadata_mut(&mut self) -> &mut HashMap; + + fn get_bool(&self, entry: &str) -> bool { + matches!(self.metadata().get(entry), Some(entry) if entry.eq("true")) + } + + fn get_usize(&self, entry: &str) -> Option { + self.metadata().get(entry)?.parse().ok() + } + + fn get(&self, entry: &str) -> Option<&String> { + self.metadata().get(entry) + } + + metadata_entry!(is_from_queue use get_bool, set_from_queue, remove_from_queue (is_queued: IS_QUEUED) -> bool); + metadata_entry!(is_from_autoplay use get_bool, set_from_autoplay, remove_from_autoplay (is_autoplay: IS_AUTOPLAY) -> bool); + metadata_entry!(is_hidden use get_bool, set_hidden, remove_hidden (is_hidden: HIDDEN) -> bool); + + metadata_entry!(get_context_index use get_usize, set_context_index, remove_context_index (context_index: CUSTOM_CONTEXT_INDEX) -> Option); + metadata_entry!(get_context_uri, set_context_uri, remove_context_uri (context_uri: CONTEXT_URI)); + metadata_entry!(get_entity_uri, set_entity_uri, remove_entity_uri (entity_uri: ENTITY_URI)); + metadata_entry!(get_iteration, set_iteration, remove_iteration (iteration: ITERATION)); + metadata_entry!(get_shuffle_seed, set_shuffle_seed, remove_shuffle_seed (shuffle_seed: CUSTOM_SHUFFLE_SEED)); + metadata_entry!(get_initial_track, set_initial_track, remove_initial_track (initial_track: CUSTOM_INITIAL_TRACK)); +} + +macro_rules! impl_metadata { + ($impl_for:ident) => { + impl Metadata for $impl_for { + fn metadata(&self) -> &HashMap { + &self.metadata + } + + fn metadata_mut(&mut self) -> &mut HashMap { + &mut self.metadata + } + } + }; +} + +impl_metadata!(ContextTrack); +impl_metadata!(ProvidedTrack); +impl_metadata!(Context); +impl_metadata!(StateContext); diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs new file mode 100644 index 00000000..efb83764 --- /dev/null +++ b/connect/src/state/options.rs @@ -0,0 +1,113 @@ +use crate::{ + core::Error, + protocol::player::ContextPlayerOptions, + state::{ + ConnectState, StateError, + context::{ContextType, ResetContext}, + metadata::Metadata, + }, +}; +use protobuf::MessageField; +use rand::Rng; + +#[derive(Default, Debug)] +pub(crate) struct ShuffleState { + pub seed: u64, + pub initial_track: String, +} + +impl ConnectState { + fn add_options_if_empty(&mut self) { + if self.player().options.is_none() { + self.player_mut().options = MessageField::some(ContextPlayerOptions::new()) + } + } + + pub fn set_repeat_context(&mut self, repeat: bool) { + self.add_options_if_empty(); + if let Some(options) = self.player_mut().options.as_mut() { + options.repeating_context = repeat; + } + } + + pub fn set_repeat_track(&mut self, repeat: bool) { + self.add_options_if_empty(); + if let Some(options) = self.player_mut().options.as_mut() { + options.repeating_track = repeat; + } + } + + pub fn set_shuffle(&mut self, shuffle: bool) { + self.add_options_if_empty(); + if let Some(options) = self.player_mut().options.as_mut() { + options.shuffling_context = shuffle; + } + } + + pub fn reset_options(&mut self) { + self.set_shuffle(false); + self.set_repeat_track(false); + self.set_repeat_context(false); + } + + fn validate_shuffle_allowed(&self) -> Result<(), Error> { + if let Some(reason) = self + .player() + .restrictions + .disallow_toggling_shuffle_reasons + .first() + { + Err(StateError::CurrentlyDisallowed { + action: "shuffle", + reason: reason.clone(), + })? + } else { + Ok(()) + } + } + + pub fn shuffle_restore(&mut self, shuffle_state: ShuffleState) -> Result<(), Error> { + self.validate_shuffle_allowed()?; + + self.shuffle(shuffle_state.seed, &shuffle_state.initial_track) + } + + pub fn shuffle_new(&mut self) -> Result<(), Error> { + self.validate_shuffle_allowed()?; + + let new_seed = rand::rng().random_range(100_000_000_000..1_000_000_000_000); + let current_track = self.current_track(|t| t.uri.clone()); + + self.shuffle(new_seed, ¤t_track) + } + + fn shuffle(&mut self, seed: u64, initial_track: &str) -> Result<(), Error> { + self.clear_prev_track(); + self.clear_next_tracks(); + + self.reset_context(ResetContext::DefaultIndex); + + let ctx = self.get_context_mut(ContextType::Default)?; + ctx.tracks + .shuffle_with_seed(seed, |f| f.uri == initial_track); + + ctx.set_initial_track(initial_track); + ctx.set_shuffle_seed(seed); + + self.fill_up_next_tracks()?; + + Ok(()) + } + + pub fn shuffling_context(&self) -> bool { + self.player().options.shuffling_context + } + + pub fn repeat_context(&self) -> bool { + self.player().options.repeating_context + } + + pub fn repeat_track(&self) -> bool { + self.player().options.repeating_track + } +} diff --git a/connect/src/state/provider.rs b/connect/src/state/provider.rs new file mode 100644 index 00000000..97eb7aa4 --- /dev/null +++ b/connect/src/state/provider.rs @@ -0,0 +1,66 @@ +use librespot_protocol::player::ProvidedTrack; +use std::fmt::{Display, Formatter}; + +// providers used by spotify +const PROVIDER_CONTEXT: &str = "context"; +const PROVIDER_QUEUE: &str = "queue"; +const PROVIDER_AUTOPLAY: &str = "autoplay"; + +// custom providers, used to identify certain states that we can't handle preemptively, yet +/// it seems like spotify just knows that the track isn't available, currently we don't have an +/// option to do the same, so we stay with the old solution for now +const PROVIDER_UNAVAILABLE: &str = "unavailable"; + +#[derive(Debug, Clone)] +pub enum Provider { + Context, + Queue, + Autoplay, + Unavailable, +} + +impl Display for Provider { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Provider::Context => PROVIDER_CONTEXT, + Provider::Queue => PROVIDER_QUEUE, + Provider::Autoplay => PROVIDER_AUTOPLAY, + Provider::Unavailable => PROVIDER_UNAVAILABLE, + } + ) + } +} + +pub trait IsProvider { + fn is_autoplay(&self) -> bool; + fn is_context(&self) -> bool; + fn is_queue(&self) -> bool; + fn is_unavailable(&self) -> bool; + + fn set_provider(&mut self, provider: Provider); +} + +impl IsProvider for ProvidedTrack { + fn is_autoplay(&self) -> bool { + self.provider == PROVIDER_AUTOPLAY + } + + fn is_context(&self) -> bool { + self.provider == PROVIDER_CONTEXT + } + + fn is_queue(&self) -> bool { + self.provider == PROVIDER_QUEUE + } + + fn is_unavailable(&self) -> bool { + self.provider == PROVIDER_UNAVAILABLE + } + + fn set_provider(&mut self, provider: Provider) { + self.provider = provider.to_string() + } +} diff --git a/connect/src/state/restrictions.rs b/connect/src/state/restrictions.rs new file mode 100644 index 00000000..29d8d475 --- /dev/null +++ b/connect/src/state/restrictions.rs @@ -0,0 +1,62 @@ +use crate::state::ConnectState; +use crate::state::provider::IsProvider; +use librespot_protocol::player::Restrictions; +use protobuf::MessageField; + +impl ConnectState { + pub fn clear_restrictions(&mut self) { + let player = self.player_mut(); + + player.context_restrictions = Some(Default::default()).into(); + player.restrictions = Some(Default::default()).into(); + } + + pub fn update_restrictions(&mut self) { + const NO_PREV: &str = "no previous tracks"; + const AUTOPLAY: &str = "autoplay"; + + let prev_tracks_is_empty = self.prev_tracks().is_empty(); + + let is_paused = self.is_pause(); + let is_playing = self.is_playing(); + + let player = self.player_mut(); + if let Some(restrictions) = player.restrictions.as_mut() { + if is_playing { + restrictions.disallow_pausing_reasons.clear(); + restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()] + } + + if is_paused { + restrictions.disallow_resuming_reasons.clear(); + restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()] + } + } + + if player.restrictions.is_none() { + player.restrictions = MessageField::some(Restrictions::new()) + } + + if let Some(restrictions) = player.restrictions.as_mut() { + if prev_tracks_is_empty { + restrictions.disallow_peeking_prev_reasons = vec![NO_PREV.to_string()]; + restrictions.disallow_skipping_prev_reasons = vec![NO_PREV.to_string()]; + } else { + restrictions.disallow_peeking_prev_reasons.clear(); + restrictions.disallow_skipping_prev_reasons.clear(); + } + + if player.track.is_autoplay() { + restrictions.disallow_toggling_shuffle_reasons = vec![AUTOPLAY.to_string()]; + restrictions.disallow_toggling_repeat_context_reasons = vec![AUTOPLAY.to_string()]; + restrictions.disallow_toggling_repeat_track_reasons = vec![AUTOPLAY.to_string()]; + } else { + restrictions.disallow_toggling_shuffle_reasons.clear(); + restrictions + .disallow_toggling_repeat_context_reasons + .clear(); + restrictions.disallow_toggling_repeat_track_reasons.clear(); + } + } + } +} diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs new file mode 100644 index 00000000..8619035c --- /dev/null +++ b/connect/src/state/tracks.rs @@ -0,0 +1,439 @@ +use crate::{ + core::{Error, SpotifyUri}, + protocol::player::ProvidedTrack, + state::{ + ConnectState, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE, StateError, + context::ContextType, + metadata::Metadata, + provider::{IsProvider, Provider}, + }, +}; +use protobuf::MessageField; +use rand::Rng; + +// identifier used as part of the uid +pub const IDENTIFIER_DELIMITER: &str = "delimiter"; + +impl<'ct> ConnectState { + fn new_delimiter(iteration: i64) -> ProvidedTrack { + let mut delimiter = ProvidedTrack { + uri: format!("spotify:{IDENTIFIER_DELIMITER}"), + uid: format!("{IDENTIFIER_DELIMITER}{iteration}"), + provider: Provider::Context.to_string(), + ..Default::default() + }; + delimiter.set_hidden(true); + delimiter.set_iteration(iteration); + + delimiter + } + + fn push_prev(&mut self, prev: ProvidedTrack) { + let prev_tracks = self.prev_tracks_mut(); + // add prev track, while preserving a length of 10 + if prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { + // todo: O(n), but technically only maximal O(SPOTIFY_MAX_PREV_TRACKS_SIZE) aka O(10) + let _ = prev_tracks.remove(0); + } + prev_tracks.push(prev) + } + + fn get_next_track(&mut self) -> Option { + if self.next_tracks().is_empty() { + None + } else { + // todo: O(n), but technically only maximal O(SPOTIFY_MAX_NEXT_TRACKS_SIZE) aka O(80) + Some(self.next_tracks_mut().remove(0)) + } + } + + /// bottom => top, aka the last track of the list is the prev track + fn prev_tracks_mut(&mut self) -> &mut Vec { + &mut self.player_mut().prev_tracks + } + + /// bottom => top, aka the last track of the list is the prev track + pub(super) fn prev_tracks(&self) -> &Vec { + &self.player().prev_tracks + } + + /// top => bottom, aka the first track of the list is the next track + fn next_tracks_mut(&mut self) -> &mut Vec { + &mut self.player_mut().next_tracks + } + + /// top => bottom, aka the first track of the list is the next track + pub(super) fn next_tracks(&self) -> &Vec { + &self.player().next_tracks + } + + pub fn set_current_track_random(&mut self) -> Result<(), Error> { + let max_tracks = self.get_context(self.active_context)?.tracks.len(); + let rng_track = rand::rng().random_range(0..max_tracks); + self.set_current_track(rng_track) + } + + pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> { + let context = self.get_context(self.active_context)?; + + let new_track = context + .tracks + .get(index) + .ok_or(StateError::CanNotFindTrackInContext( + Some(index), + context.tracks.len(), + ))?; + + debug!( + "set track to: {} at {} of {} tracks", + new_track.uri, + index, + context.tracks.len() + ); + + self.set_track(new_track.clone()); + + self.update_current_index(|i| i.track = index as u32); + + Ok(()) + } + + /// Move to the next track + /// + /// Updates the current track to the next track. Adds the old track + /// to prev tracks and fills up the next tracks from the current context + pub fn next_track(&mut self) -> Result, Error> { + // when we skip in repeat track, we don't repeat the current track anymore + if self.repeat_track() { + self.set_repeat_track(false); + } + + let old_track = self.player_mut().track.take(); + + if let Some(old_track) = old_track { + // only add songs from our context to our previous tracks + if old_track.is_context() || old_track.is_autoplay() { + self.push_prev(old_track) + } + } + + let new_track = loop { + match self.get_next_track() { + Some(next) if next.uid.starts_with(IDENTIFIER_DELIMITER) => { + self.push_prev(next); + continue; + } + Some(next) if next.is_unavailable() => continue, + other => break other, + }; + }; + + let new_track = match new_track { + None => return Ok(None), + Some(t) => t, + }; + + self.fill_up_next_tracks()?; + + let update_index = if new_track.is_queue() { + None + } else if new_track.is_autoplay() { + self.set_active_context(ContextType::Autoplay); + None + } else { + match new_track.get_context_index() { + Some(new_index) => Some(new_index as u32), + None => { + error!("the given context track had no set context_index"); + None + } + } + }; + + if let Some(update_index) = update_index { + self.update_current_index(|i| i.track = update_index) + } else { + self.player_mut().index.clear() + } + + self.set_track(new_track); + self.update_restrictions(); + + Ok(Some(self.player().index.track)) + } + + /// Move to the prev track + /// + /// Updates the current track to the prev track. Adds the old track + /// to next tracks (when from the context) and fills up the prev tracks from the + /// current context + pub fn prev_track(&mut self) -> Result>, Error> { + let old_track = self.player_mut().track.take(); + + if let Some(old_track) = old_track { + if old_track.is_context() || old_track.is_autoplay() { + // todo: O(n) + self.next_tracks_mut().insert(0, old_track); + } + } + + // handle possible delimiter + if matches!(self.prev_tracks().last(), Some(prev) if prev.uid.starts_with(IDENTIFIER_DELIMITER)) + { + let delimiter = self + .prev_tracks_mut() + .pop() + .expect("item that was prechecked"); + + let next_tracks = self.next_tracks_mut(); + if next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let _ = next_tracks.pop(); + } + // todo: O(n) + next_tracks.insert(0, delimiter) + } + + while self.next_tracks().len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let _ = self.next_tracks_mut().pop(); + } + + let new_track = match self.prev_tracks_mut().pop() { + None => return Ok(None), + Some(t) => t, + }; + + if matches!(self.active_context, ContextType::Autoplay if new_track.is_context()) { + // transition back to default context + self.set_active_context(ContextType::Default); + } + + self.fill_up_next_tracks()?; + self.set_track(new_track); + + if self.player().index.track == 0 { + warn!("prev: trying to skip into negative, index update skipped") + } else { + self.update_current_index(|i| i.track -= 1) + } + + self.update_restrictions(); + + Ok(Some(self.current_track(|t| t))) + } + + pub fn current_track) -> R, R>( + &'ct self, + access: F, + ) -> R { + access(&self.player().track) + } + + pub fn set_track(&mut self, track: ProvidedTrack) { + self.player_mut().track = MessageField::some(track) + } + + pub fn set_next_tracks(&mut self, mut tracks: Vec) { + // mobile only sends a set_queue command instead of an add_to_queue command + // in addition to handling the mobile add_to_queue handling, this should also handle + // a mass queue addition + tracks + .iter_mut() + .filter(|t| t.is_from_queue()) + .for_each(|t| { + t.set_provider(Provider::Queue); + // technically we could preserve the queue-uid here, + // but it seems to work without that, so we just override it + t.uid = format!("q{}", self.queue_count); + self.queue_count += 1; + }); + + // when you drag 'n drop the current track in the queue view into the "Next from: ..." + // section, it is only send as an empty item with just the provider and metadata, so we have + // to provide set the uri from the current track manually + tracks + .iter_mut() + .filter(|t| t.uri.is_empty()) + .for_each(|t| t.uri = self.current_track(|ct| ct.uri.clone())); + + self.player_mut().next_tracks = tracks; + } + + pub fn set_prev_tracks(&mut self, tracks: Vec) { + self.player_mut().prev_tracks = tracks; + } + + pub fn clear_prev_track(&mut self) { + self.prev_tracks_mut().clear() + } + + pub fn clear_next_tracks(&mut self) { + // respect queued track and don't throw them out of our next played tracks + let first_non_queued_track = self + .next_tracks() + .iter() + .enumerate() + .find(|(_, track)| !track.is_queue()); + + if let Some((non_queued_track, _)) = first_non_queued_track { + while self.next_tracks().len() > non_queued_track + && self.next_tracks_mut().pop().is_some() + {} + } + } + + pub fn fill_up_next_tracks(&mut self) -> Result<(), Error> { + let ctx = self.get_context(self.fill_up_context)?; + let mut new_index = ctx.index.track as usize; + let mut iteration = ctx.index.page; + + while self.next_tracks().len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let ctx = self.get_context(self.fill_up_context)?; + let track = match ctx.tracks.get(new_index) { + None if self.repeat_context() => { + let delimiter = Self::new_delimiter(iteration.into()); + iteration += 1; + new_index = 0; + delimiter + } + None if !matches!(self.fill_up_context, ContextType::Autoplay) + && self.autoplay_context.is_some() + && !self.repeat_context() => + { + self.update_context_index(self.fill_up_context, new_index)?; + + // transition to autoplay as fill up context + self.fill_up_context = ContextType::Autoplay; + new_index = self.get_context(ContextType::Autoplay)?.index.track as usize; + + // add delimiter to only display the current context + Self::new_delimiter(iteration.into()) + } + None if self.autoplay_context.is_some() => { + match self + .get_context(ContextType::Autoplay)? + .tracks + .get(new_index) + { + None => break, + Some(ct) => { + new_index += 1; + ct.clone() + } + } + } + None => break, + Some(ct) if ct.is_unavailable() || self.is_skip_track(ct, Some(iteration)) => { + debug!( + "skipped track {} during fillup as it's unavailable or should be skipped", + ct.uri + ); + new_index += 1; + continue; + } + Some(ct) => { + new_index += 1; + ct.clone() + } + }; + + self.next_tracks_mut().push(track); + } + + debug!( + "finished filling up next_tracks ({})", + self.next_tracks().len() + ); + + self.update_context_index(self.fill_up_context, new_index)?; + + // the web-player needs a revision update, otherwise the queue isn't updated in the ui + self.update_queue_revision(); + + Ok(()) + } + + pub fn preview_next_track(&mut self) -> Option { + let next = if self.repeat_track() { + self.current_track(|t| &t.uri) + } else { + &self.next_tracks().first()?.uri + }; + + SpotifyUri::from_uri(next).ok() + } + + pub fn has_next_tracks(&self, min: Option) -> bool { + if let Some(min) = min { + self.next_tracks().len() >= min + } else { + !self.next_tracks().is_empty() + } + } + + pub fn recent_track_uris(&self) -> Vec { + let mut prev = self + .prev_tracks() + .iter() + .map(|t| t.uri.clone()) + .collect::>(); + + prev.push(self.current_track(|t| t.uri.clone())); + prev + } + + pub fn mark_unavailable(&mut self, id: &SpotifyUri) -> Result<(), Error> { + let uri = id.to_uri()?; + + debug!("marking {uri} as unavailable"); + + let next_tracks = self.next_tracks_mut(); + while let Some(pos) = next_tracks.iter().position(|t| t.uri == uri) { + let _ = next_tracks.remove(pos); + } + + for next_track in next_tracks { + Self::mark_as_unavailable_for_match(next_track, &uri) + } + + let prev_tracks = self.prev_tracks_mut(); + while let Some(pos) = prev_tracks.iter().position(|t| t.uri == uri) { + let _ = prev_tracks.remove(pos); + } + + for prev_track in prev_tracks { + Self::mark_as_unavailable_for_match(prev_track, &uri) + } + + self.unavailable_uri.push(uri); + self.fill_up_next_tracks()?; + self.update_queue_revision(); + + Ok(()) + } + + pub fn add_to_queue(&mut self, mut track: ProvidedTrack, rev_update: bool) { + track.uid = format!("q{}", self.queue_count); + self.queue_count += 1; + + track.set_provider(Provider::Queue); + if !track.is_from_queue() { + track.set_from_queue(true); + } + + let next_tracks = self.next_tracks_mut(); + if let Some(next_not_queued_track) = next_tracks.iter().position(|t| !t.is_queue()) { + next_tracks.insert(next_not_queued_track, track); + } else { + next_tracks.push(track) + } + + while next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + next_tracks.pop(); + } + + if rev_update { + self.update_queue_revision(); + } + self.update_restrictions(); + } +} diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs new file mode 100644 index 00000000..8fdb21fa --- /dev/null +++ b/connect/src/state/transfer.rs @@ -0,0 +1,188 @@ +use crate::{ + core::Error, + protocol::{player::ProvidedTrack, transfer_state::TransferState}, + state::{ + context::ContextType, + metadata::Metadata, + options::ShuffleState, + provider::{IsProvider, Provider}, + {ConnectState, StateError}, + }, +}; +use protobuf::MessageField; + +impl ConnectState { + pub fn current_track_from_transfer( + &self, + transfer: &TransferState, + ) -> Result { + let track = if transfer.queue.is_playing_queue.unwrap_or_default() { + transfer.queue.tracks.first() + } else { + transfer.playback.current_track.as_ref() + } + .ok_or(StateError::CouldNotResolveTrackFromTransfer)?; + + self.context_to_provided_track( + track, + transfer.current_session.context.uri.as_deref(), + None, + None, + transfer + .queue + .is_playing_queue + .unwrap_or_default() + .then_some(Provider::Queue), + ) + } + + /// handles the initially transferable data + pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState) { + let current_context_metadata = self.context.as_ref().map(|c| c.metadata.clone()); + let player = self.player_mut(); + + player.is_buffering = false; + + if let Some(options) = transfer.options.take() { + player.options = MessageField::some(options.into()); + } + player.is_paused = transfer.playback.is_paused.unwrap_or_default(); + player.is_playing = !player.is_paused; + + match transfer.playback.playback_speed { + Some(speed) if speed != 0. => player.playback_speed = speed, + _ => player.playback_speed = 1., + } + + let mut shuffle_seed = None; + let mut initial_track = None; + if let Some(session) = transfer.current_session.as_mut() { + player.play_origin = session.play_origin.take().map(Into::into).into(); + player.suppressions = session.suppressions.take().map(Into::into).into(); + + // maybe at some point we can use the shuffle seed provided by spotify, + // but I doubt it, as spotify doesn't use true randomness but rather an algorithm + // based shuffle + trace!( + "shuffle_seed: <{:?}> (spotify), <{:?}> (own)", + session.shuffle_seed, + session.context.get_shuffle_seed() + ); + + shuffle_seed = session + .context + .get_shuffle_seed() + .and_then(|seed| seed.parse().ok()); + + initial_track = session.context.get_initial_track().cloned(); + + if let Some(mut ctx) = session.context.take() { + player.restrictions = ctx.restrictions.take().map(Into::into).into(); + for (key, value) in ctx.metadata { + player.context_metadata.insert(key, value); + } + } + } + + player.context_url.clear(); + player.context_uri.clear(); + + if let Some(metadata) = current_context_metadata { + for (key, value) in metadata { + player.context_metadata.insert(key, value); + } + } + + self.transfer_shuffle = match (shuffle_seed, initial_track) { + (Some(seed), Some(initial_track)) => Some(ShuffleState { + seed, + initial_track, + }), + _ => None, + }; + + self.clear_prev_track(); + self.clear_next_tracks(); + self.update_queue_revision() + } + + /// completes the transfer, loading the queue and updating metadata + pub fn finish_transfer(&mut self, transfer: TransferState) -> Result<(), Error> { + let track = match self.player().track.as_ref() { + None => self.current_track_from_transfer(&transfer)?, + Some(track) => track.clone(), + }; + + let context_ty = if self.current_track(|t| t.is_from_autoplay()) { + ContextType::Autoplay + } else { + ContextType::Default + }; + + self.set_active_context(context_ty); + self.fill_up_context = context_ty; + + let ctx = self.get_context(self.active_context)?; + + let current_index = match transfer.current_session.current_uid.as_ref() { + Some(uid) if track.is_queue() => Self::find_index_in_context(ctx, |c| &c.uid == uid) + .map(|i| if i > 0 { i - 1 } else { i }), + _ => Self::find_index_in_context(ctx, |c| c.uri == track.uri || c.uid == track.uid), + }; + + debug!( + "active track is <{}> with index {current_index:?} in {:?} context, has {} tracks", + track.uri, + self.active_context, + ctx.tracks.len() + ); + + if self.player().track.is_none() { + self.set_track(track); + } + + let current_index = current_index.ok(); + if let Some(current_index) = current_index { + self.update_current_index(|i| i.track = current_index as u32); + } + + debug!( + "setting up next and prev: index is at {current_index:?} while shuffle {}", + self.shuffling_context() + ); + + for (i, track) in transfer.queue.tracks.iter().enumerate() { + if transfer.queue.is_playing_queue.unwrap_or_default() && i == 0 { + // if we are currently playing from the queue, + // don't add the first queued item, because we are currently playing that item + continue; + } + + if let Ok(queued_track) = self.context_to_provided_track( + track, + Some(self.context_uri()), + None, + None, + Some(Provider::Queue), + ) { + self.add_to_queue(queued_track, false); + } + } + + if self.shuffling_context() { + self.set_current_track(current_index.unwrap_or_default())?; + self.set_shuffle(true); + + match self.transfer_shuffle.take() { + None => self.shuffle_new(), + Some(state) => self.shuffle_restore(state), + }? + } else { + self.reset_playback_to_position(current_index)?; + } + + self.update_restrictions(); + + Ok(()) + } +} diff --git a/contrib/Dockerfile b/contrib/Dockerfile index 74b83d31..9b3a5a81 100644 --- a/contrib/Dockerfile +++ b/contrib/Dockerfile @@ -2,52 +2,63 @@ # Build the docker image from the root of the project with the following command : # $ docker build -t librespot-cross -f contrib/Dockerfile . # -# The resulting image can be used to build librespot for linux x86_64, armhf(with support for armv6hf), armel, mipsel, aarch64 +# The resulting image can be used to build librespot for linux x86_64, armhf, armel, aarch64 # $ docker run -v /tmp/librespot-build:/build librespot-cross # # The compiled binaries will be located in /tmp/librespot-build # # 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 contrib/docker-build-pi-armv6hf.sh +# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" +# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" +# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" +# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target aarch64-unknown-linux-gnu --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" -FROM debian:stretch +FROM debian:bookworm -RUN dpkg --add-architecture arm64 -RUN dpkg --add-architecture armhf -RUN dpkg --add-architecture armel -RUN dpkg --add-architecture mipsel -RUN apt-get update +RUN echo "deb http://deb.debian.org/debian bookworm main" > /etc/apt/sources.list && \ + echo "deb http://deb.debian.org/debian bookworm-updates main" >> /etc/apt/sources.list && \ + echo "deb http://deb.debian.org/debian-security bookworm-security main" >> /etc/apt/sources.list -RUN apt-get install -y curl git build-essential crossbuild-essential-arm64 crossbuild-essential-armel crossbuild-essential-armhf crossbuild-essential-mipsel pkg-config -RUN apt-get install -y libasound2-dev libasound2-dev:arm64 libasound2-dev:armel libasound2-dev:armhf libasound2-dev:mipsel +RUN dpkg --add-architecture arm64 && \ + dpkg --add-architecture armhf && \ + dpkg --add-architecture armel && \ + apt-get update && \ + apt-get install -y \ + build-essential \ + cmake \ + crossbuild-essential-arm64 \ + crossbuild-essential-armel \ + crossbuild-essential-armhf \ + curl \ + git \ + libasound2-dev \ + libasound2-dev:arm64 \ + libasound2-dev:armel \ + libasound2-dev:armhf \ + libclang-dev \ + libpulse0 \ + libpulse0:arm64 \ + libpulse0:armel \ + libpulse0:armhf \ + pkg-config -RUN curl https://sh.rustup.rs -sSf | sh -s -- -y ENV PATH="/root/.cargo/bin/:${PATH}" -RUN rustup target add aarch64-unknown-linux-gnu -RUN rustup target add arm-unknown-linux-gnueabi -RUN rustup target add arm-unknown-linux-gnueabihf -RUN rustup target add mipsel-unknown-linux-gnu +RUN curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain 1.85 -y && \ + rustup target add aarch64-unknown-linux-gnu && \ + rustup target add arm-unknown-linux-gnueabi && \ + rustup target add arm-unknown-linux-gnueabihf && \ + cargo install bindgen-cli && \ + mkdir /.cargo && \ + echo '[target.aarch64-unknown-linux-gnu]\nlinker = "aarch64-linux-gnu-gcc"' > /.cargo/config && \ + echo '[target.arm-unknown-linux-gnueabihf]\nlinker = "arm-linux-gnueabihf-gcc"' >> /.cargo/config && \ + echo '[target.arm-unknown-linux-gnueabi]\nlinker = "arm-linux-gnueabi-gcc"' >> /.cargo/config -RUN mkdir /.cargo && \ - echo '[target.aarch64-unknown-linux-gnu]\nlinker = "aarch64-linux-gnu-gcc"' > /.cargo/config && \ - echo '[target.arm-unknown-linux-gnueabihf]\nlinker = "arm-linux-gnueabihf-gcc"' >> /.cargo/config && \ - echo '[target.arm-unknown-linux-gnueabi]\nlinker = "arm-linux-gnueabi-gcc"' >> /.cargo/config && \ - echo '[target.mipsel-unknown-linux-gnu]\nlinker = "mipsel-linux-gnu-gcc"' >> /.cargo/config - -RUN mkdir /build && \ - mkdir /pi-tools && \ - curl -L https://github.com/raspberrypi/tools/archive/648a6eeb1e3c2b40af4eb34d88941ee0edeb3e9a.tar.gz | tar xz --strip-components 1 -C /pi-tools - -ENV CARGO_TARGET_DIR /build -ENV CARGO_HOME /build/cache +ENV CARGO_TARGET_DIR=/build +ENV CARGO_HOME=/build/cache ENV PKG_CONFIG_ALLOW_CROSS=1 ENV PKG_CONFIG_PATH_aarch64-unknown-linux-gnu=/usr/lib/aarch64-linux-gnu/pkgconfig/ ENV PKG_CONFIG_PATH_arm-unknown-linux-gnueabihf=/usr/lib/arm-linux-gnueabihf/pkgconfig/ ENV PKG_CONFIG_PATH_arm-unknown-linux-gnueabi=/usr/lib/arm-linux-gnueabi/pkgconfig/ -ENV PKG_CONFIG_PATH_mipsel-unknown-linux-gnu=/usr/lib/mipsel-linux-gnu/pkgconfig/ ADD . /src WORKDIR /src diff --git a/contrib/cross-compile-armv6hf/Dockerfile b/contrib/cross-compile-armv6hf/Dockerfile new file mode 100644 index 00000000..86ac2644 --- /dev/null +++ b/contrib/cross-compile-armv6hf/Dockerfile @@ -0,0 +1,44 @@ +# Cross compilation environment for librespot in armv6hf. +# Build the docker image from the root of the project with the following command: +# $ docker build -t librespot-cross-armv6hf -f contrib/cross-compile-armv6hf/Dockerfile . +# +# The resulting image can be used to build librespot for armv6hf: +# $ docker run -v /tmp/librespot-build-armv6hf:/build librespot-cross-armv6hf +# +# The compiled binary will be located in /tmp/librespot-build-armv6hf/arm-unknown-linux-gnueabihf/release/librespot + +FROM --platform=linux/amd64 ubuntu:18.04 + +# Install common packages. +RUN apt-get update +RUN apt-get install -y -qq git curl build-essential cmake clang libclang-dev libasound2-dev libpulse-dev + +# Install armhf packages. +RUN echo "deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports/ bionic main" | tee -a /etc/apt/sources.list +RUN apt-get update +RUN apt-get download libasound2:armhf libasound2-dev:armhf +RUN mkdir /sysroot && \ + dpkg -x libasound2_*.deb /sysroot/ && \ + dpkg -x libasound2-dev*.deb /sysroot/ + +# Install rust. +RUN curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain 1.85 -y +ENV PATH="/root/.cargo/bin/:${PATH}" +RUN rustup target add arm-unknown-linux-gnueabihf +RUN mkdir /.cargo && \ + echo '[target.arm-unknown-linux-gnueabihf]\nlinker = "arm-linux-gnueabihf-gcc"' >> /.cargo/config + +# Install Pi tools for armv6. +RUN mkdir /pi && \ + git -C /pi clone --depth=1 https://github.com/raspberrypi/tools.git + +# Build env variables. +ENV CARGO_TARGET_DIR=/build +ENV CARGO_HOME=/build/cache +ENV PATH="/pi/tools/arm-bcm2708/arm-linux-gnueabihf/bin:${PATH}" +ENV PKG_CONFIG_ALLOW_CROSS=1 +ENV PKG_CONFIG_PATH_arm-unknown-linux-gnueabihf=/usr/lib/arm-linux-gnueabihf/pkgconfig/ + +ADD . /src +WORKDIR /src +CMD ["/src/contrib/cross-compile-armv6hf/docker-build.sh"] diff --git a/contrib/cross-compile-armv6hf/docker-build.sh b/contrib/cross-compile-armv6hf/docker-build.sh new file mode 100755 index 00000000..08386186 --- /dev/null +++ b/contrib/cross-compile-armv6hf/docker-build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -eux + +cargo install --force --locked bindgen-cli + +PI1_TOOLS_DIR=/pi/tools/arm-bcm2708/arm-linux-gnueabihf +PI1_TOOLS_SYSROOT_DIR=$PI1_TOOLS_DIR/arm-linux-gnueabihf/sysroot + +PI1_LIB_DIRS=( + "$PI1_TOOLS_SYSROOT_DIR/lib" + "$PI1_TOOLS_SYSROOT_DIR/usr/lib" + "/sysroot/usr/lib/arm-linux-gnueabihf" +) +export RUSTFLAGS="-C linker=$PI1_TOOLS_DIR/bin/arm-linux-gnueabihf-gcc ${PI1_LIB_DIRS[*]/#/-L}" +export BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$PI1_TOOLS_SYSROOT_DIR + +cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" diff --git a/contrib/docker-build-pi-armv6hf.sh b/contrib/docker-build-pi-armv6hf.sh deleted file mode 100755 index 9cc52a98..00000000 --- a/contrib/docker-build-pi-armv6hf.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash - -# Snipped and tucked from https://github.com/plietar/librespot/pull/202/commits/21549641d39399cbaec0bc92b36c9951d1b87b90 -# and further inputs from https://github.com/kingosticks/librespot/commit/c55dd20bd6c7e44dd75ff33185cf50b2d3bd79c3 - -set -eux -# Get alsa lib and headers -ALSA_VER="1.0.25-4" -DEPS=( \ - "http://mirrordirector.raspbian.org/raspbian/pool/main/a/alsa-lib/libasound2_${ALSA_VER}_armhf.deb" \ - "http://mirrordirector.raspbian.org/raspbian/pool/main/a/alsa-lib/libasound2-dev_${ALSA_VER}_armhf.deb" \ -) - -# Collect Paths -SYSROOT="/pi-tools/arm-bcm2708/arm-bcm2708hardfp-linux-gnueabi/arm-bcm2708hardfp-linux-gnueabi/sysroot" -GCC="/pi-tools/arm-bcm2708/gcc-linaro-arm-linux-gnueabihf-raspbian-x64/bin" -GCC_SYSROOT="$GCC/gcc-sysroot" - - -export PATH=/pi-tools/arm-bcm2708/gcc-linaro-arm-linux-gnueabihf-raspbian-x64/bin/:$PATH - -# Link the compiler -export TARGET_CC="$GCC/arm-linux-gnueabihf-gcc" - -# Create wrapper around gcc to point to rpi sysroot -echo -e '#!/bin/bash' "\n$TARGET_CC --sysroot $SYSROOT \"\$@\"" > $GCC_SYSROOT -chmod +x $GCC_SYSROOT - -# Add extra target dependencies to our rpi sysroot -for path in "${DEPS[@]}"; do - curl -OL $path - dpkg -x $(basename $path) $SYSROOT -done - -# i don't why this is neccessary -# ln -s ld-linux.so.3 $SYSROOT/lib/ld-linux-armhf.so.3 - -# point cargo to use gcc wrapper as linker -echo -e '[target.arm-unknown-linux-gnueabihf]\nlinker = "gcc-sysroot"' > /.cargo/config - -# Build -cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features "alsa-backend" diff --git a/contrib/docker-build.sh b/contrib/docker-build.sh index cd5ef465..84131e07 100755 --- a/contrib/docker-build.sh +++ b/contrib/docker-build.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash set -eux -cargo build --release --no-default-features --features alsa-backend -cargo build --release --target aarch64-unknown-linux-gnu --no-default-features --features alsa-backend -cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features alsa-backend -cargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features alsa-backend -cargo build --release --target mipsel-unknown-linux-gnu --no-default-features --features alsa-backend +cargo build --release --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" +cargo build --release --target aarch64-unknown-linux-gnu --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" +cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" +cargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features "alsa-backend with-libmdns rustls-tls-native-roots" diff --git a/contrib/event_handler_example.py b/contrib/event_handler_example.py new file mode 100644 index 00000000..f419e7d6 --- /dev/null +++ b/contrib/event_handler_example.py @@ -0,0 +1,77 @@ +#!/usr/bin/python3 +import os +import json +from datetime import datetime + +player_event = os.getenv('PLAYER_EVENT') + +json_dict = { + 'event_time': str(datetime.now()), + 'event': player_event, +} + +if player_event in ('session_connected', 'session_disconnected'): + json_dict['user_name'] = os.environ['USER_NAME'] + json_dict['connection_id'] = os.environ['CONNECTION_ID'] + +elif player_event == 'session_client_changed': + json_dict['client_id'] = os.environ['CLIENT_ID'] + json_dict['client_name'] = os.environ['CLIENT_NAME'] + json_dict['client_brand_name'] = os.environ['CLIENT_BRAND_NAME'] + json_dict['client_model_name'] = os.environ['CLIENT_MODEL_NAME'] + +elif player_event == 'shuffle_changed': + json_dict['shuffle'] = os.environ['SHUFFLE'] + +elif player_event == 'repeat_changed': + json_dict['repeat'] = os.environ['REPEAT'] + +elif player_event == 'auto_play_changed': + json_dict['auto_play'] = os.environ['AUTO_PLAY'] + +elif player_event == 'filter_explicit_content_changed': + json_dict['filter'] = os.environ['FILTER'] + +elif player_event == 'volume_changed': + json_dict['volume'] = os.environ['VOLUME'] + +elif player_event in ('seeked', 'position_correction', 'playing', 'paused'): + json_dict['track_id'] = os.environ['TRACK_ID'] + json_dict['position_ms'] = os.environ['POSITION_MS'] + +elif player_event in ('unavailable', 'end_of_track', 'preload_next', 'preloading', 'loading', 'stopped'): + json_dict['track_id'] = os.environ['TRACK_ID'] + +elif player_event == 'track_changed': + common_metadata_fields = {} + item_type = os.environ['ITEM_TYPE'] + common_metadata_fields['item_type'] = item_type + common_metadata_fields['track_id'] = os.environ['TRACK_ID'] + common_metadata_fields['uri'] = os.environ['URI'] + common_metadata_fields['name'] = os.environ['NAME'] + common_metadata_fields['duration_ms'] = os.environ['DURATION_MS'] + common_metadata_fields['is_explicit'] = os.environ['IS_EXPLICIT'] + common_metadata_fields['language'] = os.environ['LANGUAGE'].split('\n') + common_metadata_fields['covers'] = os.environ['COVERS'].split('\n') + json_dict['common_metadata_fields'] = common_metadata_fields + + + if item_type == 'Track': + track_metadata_fields = {} + track_metadata_fields['number'] = os.environ['NUMBER'] + track_metadata_fields['disc_number'] = os.environ['DISC_NUMBER'] + track_metadata_fields['popularity'] = os.environ['POPULARITY'] + track_metadata_fields['album'] = os.environ['ALBUM'] + track_metadata_fields['artists'] = os.environ['ARTISTS'].split('\n') + track_metadata_fields['album_artists'] = os.environ['ALBUM_ARTISTS'].split('\n') + json_dict['track_metadata_fields'] = track_metadata_fields + + elif item_type == 'Episode': + episode_metadata_fields = {} + episode_metadata_fields['show_name'] = os.environ['SHOW_NAME'] + publish_time = datetime.utcfromtimestamp(int(os.environ['PUBLISH_TIME'])).strftime('%Y-%m-%d') + episode_metadata_fields['publish_time'] = publish_time + episode_metadata_fields['description'] = os.environ['DESCRIPTION'] + json_dict['episode_metadata_fields'] = episode_metadata_fields + +print(json.dumps(json_dict, indent = 4)) 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 2494a19a..f91a1b38 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,54 +1,120 @@ [package] name = "librespot-core" -version = "0.3.1" +version.workspace = true +rust-version.workspace = true authors = ["Paul Lietar "] -build = "build.rs" +license.workspace = true description = "The core functionality provided by librespot" -license = "MIT" -repository = "https://github.com/librespot-org/librespot" -edition = "2018" +repository.workspace = true +edition.workspace = true +build = "build.rs" -[dependencies.librespot-protocol] -path = "../protocol" -version = "0.3.1" +[features] +# Refer to the workspace Cargo.toml for the list of features +default = ["native-tls"] + +# TLS backends (mutually exclusive - see oauth/src/lib.rs for compile-time checks) +# Note: Validation is in oauth since it's compiled first in the dependency tree. +native-tls = [ + "dep:hyper-tls", + "hyper-proxy2/tls", + "librespot-oauth/native-tls", + "tokio-tungstenite/native-tls", +] +rustls-tls-native-roots = [ + "__rustls", + "hyper-proxy2/rustls", + "hyper-rustls/native-tokio", + "librespot-oauth/rustls-tls-native-roots", + "tokio-tungstenite/rustls-tls-native-roots", +] +rustls-tls-webpki-roots = [ + "__rustls", + "hyper-proxy2/rustls-webpki", + "hyper-rustls/webpki-tokio", + "librespot-oauth/rustls-tls-webpki-roots", + "tokio-tungstenite/rustls-tls-webpki-roots", +] + +# Internal features - these are not meant to be used by end users +__rustls = [] [dependencies] -aes = "0.6" -base64 = "0.13" -byteorder = "1.4" -bytes = "1.0" -form_urlencoded = "1.0" -futures-core = { version = "0.3", default-features = false } -futures-util = { version = "0.3", default-features = false, features = ["alloc", "bilock", "unstable", "sink"] } -hmac = "0.11" -httparse = "1.3" -http = "0.2" -hyper = { version = "0.14", features = ["client", "tcp", "http1"] } -hyper-proxy = { version = "0.9.1", default-features = false } +librespot-oauth = { version = "0.7.1", path = "../oauth", default-features = false } +librespot-protocol = { version = "0.7.1", path = "../protocol", default-features = false } + +aes = "0.8" +base64 = "0.22" +byteorder = "1.5" +bytes = "1" +data-encoding = "2.9" +flate2 = "1.1" +form_urlencoded = "1.2" +futures-core = "0.3" +futures-util = { version = "0.3", default-features = false, features = [ + "alloc", + "bilock", + "unstable", +] } +governor = { version = "0.10", default-features = false, features = ["std"] } +hmac = "0.12" +httparse = "1.10" +http = "1.3" +http-body-util = "0.1" +hyper = { version = "1.6", features = ["http1", "http2"] } +hyper-proxy2 = { version = "0.1", default-features = false } +hyper-rustls = { version = "0.27", default-features = false, features = [ + "http1", + "http2", + "ring", +], optional = true } +hyper-tls = { version = "0.6", optional = true } +hyper-util = { version = "0.1", default-features = false, features = [ + "client", + "http1", + "http2", +] } log = "0.4" -num-bigint = { version = "0.4", features = ["rand"] } +nonzero_ext = "0.3" +num-bigint = "0.4" +num-derive = "0.4" num-integer = "0.1" num-traits = "0.2" -once_cell = "1.5.2" -pbkdf2 = { version = "0.8", default-features = false, features = ["hmac"] } -priority-queue = "1.1" -protobuf = "2.14.0" -rand = "0.8" +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +pin-project-lite = "0.2" +priority-queue = "2.5" +protobuf = "3.7" +protobuf-json-mapping = "3.7" +quick-xml = { version = "0.38", features = ["serialize"] } +rand = { version = "0.9", default-features = false, features = ["thread_rng"] } +rsa = "0.9" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -sha-1 = "0.9" -shannon = "0.2.0" -thiserror = "1.0.7" -tokio = { version = "1.0", features = ["io-util", "net", "rt", "sync"] } -tokio-stream = "0.1.1" -tokio-util = { version = "0.6", features = ["codec"] } -url = "2.1" -uuid = { version = "0.8", default-features = false, features = ["v4"] } +sha1 = { version = "0.10", features = ["oid"] } +shannon = "0.2" +sysinfo = { version = "0.36", default-features = false, features = ["system"] } +thiserror = "2" +time = { version = "0.3", features = ["formatting", "parsing"] } +tokio = { version = "1", features = [ + "io-util", + "macros", + "net", + "rt", + "sync", + "time", +] } +tokio-stream = { version = "0.1", default-features = false } +tokio-tungstenite = { version = "0.27", default-features = false } +tokio-util = { version = "0.7", default-features = false } +url = "2" +uuid = { version = "1", default-features = false, features = ["v4"] } [build-dependencies] -rand = "0.8" -vergen = "3.0.4" +rand = { version = "0.9", default-features = false, features = ["thread_rng"] } +rand_distr = "0.5" +vergen-gitcl = { version = "1.0", default-features = false, features = [ + "build", +] } [dev-dependencies] -env_logger = "0.8" -tokio = {version = "1.0", features = ["macros"] } +tokio = { version = "1", features = ["macros"] } diff --git a/core/build.rs b/core/build.rs index 8e61c912..4413d2f3 100644 --- a/core/build.rs +++ b/core/build.rs @@ -1,17 +1,31 @@ -use rand::distributions::Alphanumeric; use rand::Rng; -use vergen::{generate_cargo_keys, ConstantsFlags}; +use rand_distr::Alphanumeric; +use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder}; -fn main() { - let mut flags = ConstantsFlags::all(); - flags.toggle(ConstantsFlags::REBUILD_ON_HEAD_CHANGE); - generate_cargo_keys(ConstantsFlags::all()).expect("Unable to generate the cargo keys!"); +fn main() -> Result<(), Box> { + let gitcl = GitclBuilder::default() + .sha(true) // outputs 'VERGEN_GIT_SHA', and sets the 'short' flag true + .commit_date(true) // outputs 'VERGEN_GIT_COMMIT_DATE' + .build()?; - let build_id: String = rand::thread_rng() - .sample_iter(Alphanumeric) - .take(8) - .map(char::from) - .collect(); + let build = BuildBuilder::default() + .build_date(true) // outputs 'VERGEN_BUILD_DATE' + .build()?; - println!("cargo:rustc-env=LIBRESPOT_BUILD_ID={}", build_id); + Emitter::default() + .add_instructions(&build)? + .add_instructions(&gitcl)? + .emit() + .expect("Unable to generate the cargo keys!"); + let build_id = match std::env::var("SOURCE_DATE_EPOCH") { + Ok(val) => val, + Err(_) => rand::rng() + .sample_iter(Alphanumeric) + .take(8) + .map(char::from) + .collect(), + }; + + println!("cargo:rustc-env=LIBRESPOT_BUILD_ID={build_id}"); + Ok(()) } diff --git a/core/src/apresolve.rs b/core/src/apresolve.rs index 759577d4..38dde797 100644 --- a/core/src/apresolve.rs +++ b/core/src/apresolve.rs @@ -1,90 +1,153 @@ -use std::error::Error; +use std::collections::VecDeque; -use hyper::client::HttpConnector; -use hyper::{Body, Client, Method, Request, Uri}; -use hyper_proxy::{Intercept, Proxy, ProxyConnector}; +use bytes::Bytes; +use hyper::{Method, Request}; use serde::Deserialize; -use url::Url; -const APRESOLVE_ENDPOINT: &str = "http://apresolve.spotify.com:80"; -const AP_FALLBACK: &str = "ap.spotify.com:443"; +use crate::Error; -#[derive(Clone, Debug, Deserialize)] -struct ApResolveData { - ap_list: Vec, +pub type SocketAddress = (String, u16); + +#[derive(Default)] +pub struct AccessPoints { + accesspoint: VecDeque, + dealer: VecDeque, + spclient: VecDeque, } -async fn try_apresolve( - proxy: Option<&Url>, - ap_port: Option, -) -> Result> { - let port = ap_port.unwrap_or(443); +#[derive(Deserialize, Default)] +pub struct ApResolveData { + accesspoint: Vec, + dealer: Vec, + spclient: Vec, +} - let mut req = Request::new(Body::empty()); - *req.method_mut() = Method::GET; - // panic safety: APRESOLVE_ENDPOINT above is valid url. - *req.uri_mut() = APRESOLVE_ENDPOINT.parse().expect("invalid AP resolve URL"); +impl ApResolveData { + // These addresses probably do some geo-location based traffic management or at least DNS-based + // load balancing. They are known to fail when the normal resolvers are up, so that's why they + // should only be used as fallback. + fn fallback() -> Self { + Self { + accesspoint: vec![String::from("ap.spotify.com:443")], + dealer: vec![String::from("dealer.spotify.com:443")], + spclient: vec![String::from("spclient.wg.spotify.com:443")], + } + } +} - let response = if let Some(url) = proxy { - // Panic safety: all URLs are valid URIs - let uri = url.to_string().parse().unwrap(); - let proxy = Proxy::new(Intercept::All, uri); - let connector = HttpConnector::new(); - let proxy_connector = ProxyConnector::from_proxy_unsecured(connector, proxy); - Client::builder() - .build(proxy_connector) - .request(req) - .await? - } else { - Client::new().request(req).await? - }; +impl AccessPoints { + fn is_any_empty(&self) -> bool { + self.accesspoint.is_empty() || self.dealer.is_empty() || self.spclient.is_empty() + } +} - let body = hyper::body::to_bytes(response.into_body()).await?; - let data: ApResolveData = serde_json::from_slice(body.as_ref())?; +component! { + ApResolver : ApResolverInner { + data: AccessPoints = AccessPoints::default(), + } +} - let ap = if ap_port.is_some() || proxy.is_some() { - data.ap_list.into_iter().find_map(|ap| { - if ap.parse::().ok()?.port()? == port { - Some(ap) - } else { - None +impl ApResolver { + // return a port if a proxy URL and/or a proxy port was specified. This is useful even when + // there is no proxy, but firewalls only allow certain ports (e.g. 443 and not 4070). + pub fn port_config(&self) -> Option { + if self.session().config().proxy.is_some() || self.session().config().ap_port.is_some() { + Some(self.session().config().ap_port.unwrap_or(443)) + } else { + None + } + } + + fn process_ap_strings(&self, data: Vec) -> VecDeque { + let filter_port = self.port_config(); + data.into_iter() + .filter_map(|ap| { + let mut split = ap.rsplitn(2, ':'); + let port = split.next()?; + let port: u16 = port.parse().ok()?; + let host = split.next()?.to_owned(); + match filter_port { + Some(filter_port) if filter_port != port => None, + _ => Some((host, port)), + } + }) + .collect() + } + + fn parse_resolve_to_access_points(&self, resolve: ApResolveData) -> AccessPoints { + AccessPoints { + accesspoint: self.process_ap_strings(resolve.accesspoint), + dealer: self.process_ap_strings(resolve.dealer), + spclient: self.process_ap_strings(resolve.spclient), + } + } + + pub async fn try_apresolve(&self) -> Result { + let req = Request::builder() + .method(Method::GET) + .uri("https://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient") + .body(Bytes::new())?; + + let body = self.session().http_client().request_body(req).await?; + let data: ApResolveData = serde_json::from_slice(body.as_ref())?; + + Ok(data) + } + + async fn apresolve(&self) { + let result = self.try_apresolve().await; + + self.lock(|inner| { + let (data, error) = match result { + Ok(data) => (data, None), + Err(e) => (ApResolveData::default(), Some(e)), + }; + + inner.data = self.parse_resolve_to_access_points(data); + + if inner.data.is_any_empty() { + warn!("Failed to resolve all access points, using fallbacks"); + if let Some(error) = error { + warn!("Resolve access points error: {error}"); + } + + let fallback = self.parse_resolve_to_access_points(ApResolveData::fallback()); + inner.data.accesspoint.extend(fallback.accesspoint); + inner.data.dealer.extend(fallback.dealer); + inner.data.spclient.extend(fallback.spclient); } }) - } else { - data.ap_list.into_iter().next() - } - .ok_or("empty AP List")?; - - Ok(ap) -} - -pub async fn apresolve(proxy: Option<&Url>, ap_port: Option) -> String { - try_apresolve(proxy, ap_port).await.unwrap_or_else(|e| { - warn!("Failed to resolve Access Point: {}", e); - warn!("Using fallback \"{}\"", AP_FALLBACK); - AP_FALLBACK.into() - }) -} - -#[cfg(test)] -mod test { - use std::net::ToSocketAddrs; - - use super::try_apresolve; - - #[tokio::test] - async fn test_apresolve() { - let ap = try_apresolve(None, None).await.unwrap(); - - // Assert that the result contains a valid host and port - ap.to_socket_addrs().unwrap().next().unwrap(); } - #[tokio::test] - async fn test_apresolve_port_443() { - let ap = try_apresolve(None, Some(443)).await.unwrap(); + fn is_any_empty(&self) -> bool { + self.lock(|inner| inner.data.is_any_empty()) + } - let port = ap.to_socket_addrs().unwrap().next().unwrap().port(); - assert_eq!(port, 443); + pub async fn resolve(&self, endpoint: &str) -> Result { + if self.is_any_empty() { + self.apresolve().await; + } + + self.lock(|inner| { + let access_point = match endpoint { + // take the first position instead of the last with `pop`, because Spotify returns + // access points with ports 4070, 443 and 80 in order of preference from highest + // to lowest. + "accesspoint" => inner.data.accesspoint.pop_front(), + "dealer" => inner.data.dealer.pop_front(), + "spclient" => inner.data.spclient.pop_front(), + _ => { + return Err(Error::unimplemented(format!( + "No implementation to resolve access point {endpoint}" + ))); + } + }; + + let access_point = access_point.ok_or_else(|| { + Error::unavailable(format!("No access point available for endpoint {endpoint}")) + })?; + + Ok(access_point) + }) } } diff --git a/core/src/audio_key.rs b/core/src/audio_key.rs index 3bce1c73..42cad43c 100644 --- a/core/src/audio_key.rs +++ b/core/src/audio_key.rs @@ -1,52 +1,84 @@ +use std::{collections::HashMap, io::Write, time::Duration}; + use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; use bytes::Bytes; -use std::collections::HashMap; -use std::io::Write; +use thiserror::Error; use tokio::sync::oneshot; -use crate::spotify_id::{FileId, SpotifyId}; -use crate::util::SeqGenerator; +use crate::{Error, FileId, SpotifyId, packet::PacketType, util::SeqGenerator}; #[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), + #[error("audio key response timeout")] + Timeout, +} + +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), + AudioKeyError::Timeout => Error::aborted(err), + } + } +} component! { AudioKeyManager : AudioKeyManagerInner { sequence: SeqGenerator = SeqGenerator::new(0), - pending: HashMap>> = HashMap::new(), + pending: HashMap>> = HashMap::new(), } } impl AudioKeyManager { - pub(crate) fn dispatch(&self, cmd: u8, mut data: Bytes) { + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { let seq = BigEndian::read_u32(data.split_to(4).as_ref()); - let sender = self.lock(|inner| inner.pending.remove(&seq)); + let sender = self + .lock(|inner| inner.pending.remove(&seq)) + .ok_or(AudioKeyError::Sequence(seq))?; - if let Some(sender) = sender { - match cmd { - 0xd => { - let mut key = [0u8; 16]; - key.copy_from_slice(data.as_ref()); - let _ = sender.send(Ok(AudioKey(key))); - } - 0xe => { - warn!( - "error audio key {:x} {:x}", - data.as_ref()[0], - data.as_ref()[1] - ); - let _ = sender.send(Err(AudioKeyError)); - } - _ => (), + match cmd { + PacketType::AesKey => { + let mut key = [0u8; 16]; + key.copy_from_slice(data.as_ref()); + sender + .send(Ok(AudioKey(key))) + .map_err(|_| AudioKeyError::Channel)? + } + PacketType::AesKeyError => { + error!( + "error audio key {:x} {:x}", + data.as_ref()[0], + data.as_ref()[1] + ); + sender + .send(Err(AudioKeyError::AesKey.into())) + .map_err(|_| AudioKeyError::Channel)? + } + _ => { + trace!("Did not expect {cmd:?} AES key packet with data {data:#?}"); + return Err(AudioKeyError::Packet(cmd as u8).into()); } } + + Ok(()) } - pub async fn request(&self, track: SpotifyId, file: FileId) -> Result { + pub async fn request(&self, track: SpotifyId, file: FileId) -> Result { let (tx, rx) = oneshot::channel(); let seq = self.lock(move |inner| { @@ -55,17 +87,24 @@ impl AudioKeyManager { seq }); - self.send_key_request(seq, track, file); - rx.await.map_err(|_| AudioKeyError)? + self.send_key_request(seq, track, file)?; + const KEY_RESPONSE_TIMEOUT: Duration = Duration::from_millis(1500); + match tokio::time::timeout(KEY_RESPONSE_TIMEOUT, rx).await { + Err(_) => { + error!("Audio key response timeout"); + Err(AudioKeyError::Timeout.into()) + } + Ok(k) => k?, + } } - fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) { + fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) -> Result<(), Error> { let mut data: Vec = Vec::new(); - data.write(&file.0).unwrap(); - data.write(&track.to_raw()).unwrap(); - data.write_u32::(seq).unwrap(); - data.write_u16::(0x0000).unwrap(); + data.write_all(&file.0)?; + data.write_all(&track.to_raw())?; + data.write_u32::(seq)?; + data.write_u16::(0x0000)?; - self.session().send_packet(0xc, data) + self.session().send_packet(PacketType::RequestKey, data) } } diff --git a/core/src/authentication.rs b/core/src/authentication.rs index 3c188ecf..0b1678dd 100644 --- a/core/src/authentication.rs +++ b/core/src/authentication.rs @@ -1,19 +1,35 @@ use std::io::{self, Read}; use aes::Aes192; +use base64::engine::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; use byteorder::{BigEndian, ByteOrder}; -use hmac::Hmac; -use pbkdf2::pbkdf2; -use protobuf::ProtobufEnum; +use pbkdf2::pbkdf2_hmac; +use protobuf::Enum; use serde::{Deserialize, Serialize}; use sha1::{Digest, Sha1}; +use thiserror::Error; -use crate::protocol::authentication::AuthenticationType; +use crate::{Error, protocol::authentication::AuthenticationType}; + +#[derive(Debug, Error)] +pub enum AuthenticationError { + #[error("unknown authentication type {0}")] + AuthType(u32), + #[error("invalid key")] + Key, +} + +impl From for Error { + fn from(err: AuthenticationError) -> Self { + Error::invalid_argument(err) + } +} /// The credentials are used to log into the Spotify API. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct Credentials { - pub username: String, + pub username: Option, #[serde(serialize_with = "serialize_protobuf_enum")] #[serde(deserialize_with = "deserialize_protobuf_enum")] @@ -34,19 +50,27 @@ impl Credentials { /// /// let creds = Credentials::with_password("my account", "my password"); /// ``` - pub fn with_password(username: impl Into, password: impl Into) -> Credentials { - Credentials { - username: username.into(), + pub fn with_password(username: impl Into, password: impl Into) -> Self { + Self { + username: Some(username.into()), auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, auth_data: password.into().into_bytes(), } } + pub fn with_access_token(token: impl Into) -> Self { + Self { + username: None, + auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN, + auth_data: token.into().into_bytes(), + } + } + pub fn with_blob( username: impl Into, encrypted_blob: impl AsRef<[u8]>, device_id: impl AsRef<[u8]>, - ) -> Credentials { + ) -> Result { fn read_u8(stream: &mut R) -> io::Result { let mut data = [0u8]; stream.read_exact(&mut data)?; @@ -60,7 +84,7 @@ impl Credentials { } let hi = read_u8(stream)? as u32; - Ok(lo & 0x7f | hi << 7) + Ok(lo & 0x7f | (hi << 7)) } fn read_bytes(stream: &mut R) -> io::Result> { @@ -77,7 +101,11 @@ impl Credentials { let key = { let mut key = [0u8; 24]; - pbkdf2::>(&secret, username.as_bytes(), 0x100, &mut key[0..20]); + if key.len() < 20 { + return Err(AuthenticationError::Key.into()); + } + + pbkdf2_hmac::(&secret, username.as_bytes(), 0x100, &mut key[0..20]); let hash = &Sha1::digest(&key[..20]); key[..20].copy_from_slice(hash); @@ -87,15 +115,13 @@ impl Credentials { // decrypt data using ECB mode without padding let blob = { - use aes::cipher::generic_array::typenum::Unsigned; use aes::cipher::generic_array::GenericArray; - use aes::cipher::{BlockCipher, NewBlockCipher}; + use aes::cipher::{BlockDecrypt, BlockSizeUser, KeyInit}; - let mut data = base64::decode(encrypted_blob).unwrap(); + let mut data = BASE64.decode(encrypted_blob)?; let cipher = Aes192::new(GenericArray::from_slice(&key)); - let block_size = ::BlockSize::to_usize(); + let block_size = Aes192::block_size(); - assert_eq!(data.len() % block_size, 0); for chunk in data.chunks_exact_mut(block_size) { cipher.decrypt_block(GenericArray::from_mut_slice(chunk)); } @@ -109,25 +135,26 @@ impl Credentials { }; let mut cursor = io::Cursor::new(blob.as_slice()); - read_u8(&mut cursor).unwrap(); - read_bytes(&mut cursor).unwrap(); - read_u8(&mut cursor).unwrap(); - let auth_type = read_int(&mut cursor).unwrap(); - let auth_type = AuthenticationType::from_i32(auth_type as i32).unwrap(); - read_u8(&mut cursor).unwrap(); - let auth_data = read_bytes(&mut cursor).unwrap(); + read_u8(&mut cursor)?; + read_bytes(&mut cursor)?; + read_u8(&mut cursor)?; + let auth_type = read_int(&mut cursor)?; + let auth_type = AuthenticationType::from_i32(auth_type as i32) + .ok_or(AuthenticationError::AuthType(auth_type))?; + read_u8(&mut cursor)?; + let auth_data = read_bytes(&mut cursor)?; - Credentials { - username, + Ok(Self { + username: Some(username), auth_type, auth_data, - } + }) } } fn serialize_protobuf_enum(v: &T, ser: S) -> Result where - T: ProtobufEnum, + T: Enum, S: serde::Serializer, { serde::Serialize::serialize(&v.value(), ser) @@ -135,7 +162,7 @@ where fn deserialize_protobuf_enum<'de, T, D>(de: D) -> Result where - T: ProtobufEnum, + T: Enum, D: serde::Deserializer<'de>, { let v: i32 = serde::Deserialize::deserialize(de)?; @@ -147,7 +174,7 @@ where T: AsRef<[u8]>, S: serde::Serializer, { - serde::Serialize::serialize(&base64::encode(v.as_ref()), ser) + serde::Serialize::serialize(&BASE64.encode(v.as_ref()), ser) } fn deserialize_base64<'de, D>(de: D) -> Result, D::Error> @@ -155,5 +182,7 @@ where D: serde::Deserializer<'de>, { let v: String = serde::Deserialize::deserialize(de)?; - base64::decode(&v).map_err(|e| serde::de::Error::custom(e.to_string())) + BASE64 + .decode(v) + .map_err(|e| serde::de::Error::custom(e.to_string())) } diff --git a/core/src/cache.rs b/core/src/cache.rs index 612b7c39..15e35d21 100644 --- a/core/src/cache.rs +++ b/core/src/cache.rs @@ -1,15 +1,31 @@ -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, Mutex}, + time::SystemTime, +}; use priority_queue::PriorityQueue; +use thiserror::Error; -use crate::authentication::Credentials; -use crate::spotify_id::FileId; +use crate::{Error, FileId, authentication::Credentials, error::ErrorKind}; + +const CACHE_LIMITER_POISON_MSG: &str = "cache limiter mutex should not be poisoned"; + +#[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 +73,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 +102,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 } @@ -125,7 +142,7 @@ impl FsSizeLimiter { let list_dir = match fs::read_dir(path) { Ok(list_dir) => list_dir, Err(e) => { - warn!("Could not read directory {:?} in cache dir: {}", path, e); + warn!("Could not read directory {path:?} in cache dir: {e}"); return; } }; @@ -134,7 +151,7 @@ impl FsSizeLimiter { let entry = match entry { Ok(entry) => entry, Err(e) => { - warn!("Could not directory {:?} in cache dir: {}", path, e); + warn!("Could not directory {path:?} in cache dir: {e}"); return; } }; @@ -150,7 +167,7 @@ impl FsSizeLimiter { limiter.add(&path, size, access_time); } Err(e) => { - warn!("Could not read file {:?} in cache dir: {}", path, e) + warn!("Could not read file {path:?} in cache dir: {e}") } } } @@ -175,21 +192,28 @@ impl FsSizeLimiter { fn add(&self, file: &Path, size: u64) { self.limiter .lock() - .unwrap() - .add(file, size, SystemTime::now()); + .expect(CACHE_LIMITER_POISON_MSG) + .add(file, size, SystemTime::now()) } fn touch(&self, file: &Path) -> bool { - self.limiter.lock().unwrap().update(file, SystemTime::now()) + self.limiter + .lock() + .expect(CACHE_LIMITER_POISON_MSG) + .update(file, SystemTime::now()) } - fn remove(&self, file: &Path) { - self.limiter.lock().unwrap().remove(file); + fn remove(&self, file: &Path) -> bool { + self.limiter + .lock() + .expect(CACHE_LIMITER_POISON_MSG) + .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,31 +221,39 @@ impl FsSizeLimiter { first = false; } - if let Err(e) = fs::remove_file(&file) { - warn!("Could not remove file {:?} from cache dir: {}", file, e); + let res = fs::remove_file(&file); + if let Err(e) = res { + warn!("Could not remove file {file:?} from cache dir: {e}"); + last_error = Some(e); } else { count += 1; } } if count > 0 { - info!("Removed {} cache files.", count); + info!("Removed {count} cache files."); + } + + 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().expect(CACHE_LIMITER_POISON_MSG).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 +266,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 +314,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,8 +326,8 @@ 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 { - warn!("Error reading credentials from cache: {}", e); + if e.kind != ErrorKind::NotFound { + warn!("Error reading credentials from cache: {e}"); } None } @@ -300,11 +338,11 @@ impl Cache { if let Some(location) = &self.credentials_location { let result = File::create(location).and_then(|mut file| { let data = serde_json::to_string(cred)?; - write!(file, "{}", data) + write!(file, "{data}") }); if let Err(e) = result { - warn!("Cannot save credentials to cache: {}", e) + warn!("Cannot save credentials to cache: {e}") } } } @@ -312,20 +350,18 @@ 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 { - warn!("Error reading volume from cache: {}", e); + if e.kind != ErrorKind::NotFound { + warn!("Error reading volume from cache: {e}"); } None } @@ -334,20 +370,25 @@ impl Cache { pub fn save_volume(&self, volume: u16) { if let Some(ref location) = self.volume_location { - let result = File::create(location).and_then(|mut file| write!(file, "{}", volume)); + let result = File::create(location).and_then(|mut file| write!(file, "{volume}")); if let Err(e) = result { - warn!("Cannot save volume to cache: {}", e); + warn!("Cannot save volume to cache: {e}"); } } } - fn file_path(&self, file: FileId) -> Option { - self.audio_location.as_ref().map(|location| { - let name = file.to_base16(); - let mut path = location.join(&name[0..2]); - path.push(&name[2..]); - path - }) + pub fn file_path(&self, file: FileId) -> Option { + match file.to_base16() { + Ok(name) => self.audio_location.as_ref().map(|location| { + let mut path = location.join(&name[0..2]); + path.push(&name[2..]); + path + }), + Err(e) => { + warn!("Invalid FileId: {e}"); + None + } + } } pub fn file(&self, file: FileId) -> Option { @@ -355,51 +396,48 @@ 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 { - warn!("Error reading file from cache: {}", e) + if e.kind() != io::ErrorKind::NotFound { + warn!("Error reading file from cache: {e}") } None } } } - pub fn save_file(&self, file: FileId, contents: &mut F) { - let path = if let Some(path) = self.file_path(file) { - path - } else { - return; - }; - let parent = path.parent().unwrap(); - - let result = fs::create_dir_all(parent) - .and_then(|_| File::create(&path)) - .and_then(|mut file| io::copy(contents, &mut file)); - - if let Ok(size) = result { - if let Some(limiter) = self.size_limiter.as_deref() { - limiter.add(&path, size); - limiter.prune(); + pub fn save_file(&self, file: FileId, contents: &mut F) -> Result { + if let Some(path) = self.file_path(file) { + if let Some(parent) = path.parent() { + if let Ok(size) = fs::create_dir_all(parent) + .and_then(|_| File::create(&path)) + .and_then(|mut file| io::copy(contents, &mut file)) + { + if let Some(limiter) = self.size_limiter.as_deref() { + limiter.add(&path, size); + limiter.prune()?; + } + return Ok(path); + } } } + Err(CacheError::Path.into()) } - pub fn remove_file(&self, file: FileId) -> Result<(), RemoveFileError> { - let path = self.file_path(file).ok_or(RemoveFileError(()))?; + pub fn remove_file(&self, file: FileId) -> Result<(), Error> { + let path = self.file_path(file).ok_or(CacheError::Path)?; - if let Err(err) = fs::remove_file(&path) { - warn!("Unable to remove file from cache: {}", err); - Err(RemoveFileError(())) - } else { - if let Some(limiter) = self.size_limiter.as_deref() { - limiter.remove(&path); - } - Ok(()) + fs::remove_file(&path)?; + if let Some(limiter) = self.size_limiter.as_deref() { + limiter.remove(&path); } + + Ok(()) } } diff --git a/core/src/cdn_url.rs b/core/src/cdn_url.rs new file mode 100644 index 00000000..818f68f5 --- /dev/null +++ b/core/src/cdn_url.rs @@ -0,0 +1,259 @@ +use std::ops::{Deref, DerefMut}; + +use protobuf::Message; +use thiserror::Error; +use time::Duration; +use url::Url; + +use super::{Error, FileId, Session, date::Date}; + +use librespot_protocol as protocol; +use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage; +use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result; + +#[derive(Debug, Clone)] +pub struct MaybeExpiringUrl(pub String, pub Option); + +const CDN_URL_EXPIRY_MARGIN: Duration = Duration::seconds(5 * 60); + +#[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) + } + + #[deprecated = "This function only returns the first valid URL. Use try_get_urls instead, which allows for fallback logic."] + pub fn try_get_url(&self) -> Result<&str, Error> { + if self.urls.is_empty() { + return Err(CdnUrlError::Unresolved.into()); + } + + let now = Date::now_utc(); + let url = self.urls.iter().find(|url| match url.1 { + Some(expiry) => now < expiry, + None => true, + }); + + if let Some(url) = url { + Ok(&url.0) + } else { + Err(CdnUrlError::Expired.into()) + } + } + + pub fn try_get_urls(&self) -> Result, Error> { + if self.urls.is_empty() { + return Err(CdnUrlError::Unresolved.into()); + } + + let now = Date::now_utc(); + let urls: Vec<&str> = self + .urls + .iter() + .filter_map(|MaybeExpiringUrl(url, expiry)| match *expiry { + Some(expiry) => { + if now < expiry { + Some(url.as_str()) + } else { + None + } + } + None => Some(url.as_str()), + }) + .collect(); + + if urls.is_empty() { + Err(CdnUrlError::Expired.into()) + } else { + Ok(urls) + } + } +} + +impl TryFrom for MaybeExpiringUrls { + type Error = crate::Error; + fn try_from(msg: CdnUrlMessage) -> Result { + if !matches!( + msg.result.enum_value_or_default(), + StorageResolveResponse_Result::CDN + ) { + return Err(CdnUrlError::Storage.into()); + } + + let is_expiring = !msg.fileid.is_empty(); + + let result = msg + .cdnurl + .iter() + .map(|cdn_url| { + let url = Url::parse(cdn_url)?; + let mut expiry: Option = None; + + if is_expiring { + let mut expiry_str: Option = None; + if let Some(token) = url + .query_pairs() + .into_iter() + .find(|(key, _value)| key == "verify") + { + // https://audio-cf.spotifycdn.com/audio/844ecdb297a87ebfee4399f28892ef85d9ba725f?verify=1750549951-4R3I2w2q7OfNkR%2FGH8qH7xtIKUPlDxywBuADY%2BsvMeU%3D + if let Some((expiry_str_candidate, _)) = token.1.split_once('-') { + expiry_str = Some(expiry_str_candidate.to_string()); + } + } else if let Some(token) = url + .query_pairs() + .into_iter() + .find(|(key, _value)| key == "__token__") + { + //"https://audio-ak-spotify-com.akamaized.net/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?__token__=exp=1688165560~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41", + 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 + expiry_str = Some(String::from(&slice[..end])); + } else { + expiry_str = Some(String::from(slice)); + } + } + } + } else if let Some(token) = url + .query_pairs() + .into_iter() + .find(|(key, _value)| key == "Expires") + { + //"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=", + if let Some(end) = token.1.find('~') { + // this is the only valid invariant for spotifycdn.com + let slice = &token.1[..end]; + expiry_str = Some(String::from(&slice[..end])); + } + } else if let Some(query) = url.query() { + //"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=", + let mut items = query.split('_'); + if let Some(first) = items.next() { + // this is the only valid invariant for scdn.co + expiry_str = Some(String::from(first)); + } + } + + if let Some(exp_str) = expiry_str { + if let Ok(expiry_parsed) = exp_str.parse::() { + if let Ok(expiry_at) = Date::from_timestamp_ms(expiry_parsed * 1_000) { + let with_margin = expiry_at.saturating_sub(CDN_URL_EXPIRY_MARGIN); + expiry = Some(Date::from(with_margin)); + } + } else { + warn!( + "Cannot parse CDN URL expiry timestamp '{exp_str}' from '{cdn_url}'" + ); + } + } else { + warn!("Unknown CDN URL format: {cdn_url}"); + } + } + Ok(MaybeExpiringUrl(cdn_url.to_owned(), expiry)) + }) + .collect::, Error>>()?; + + Ok(Self(result)) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_maybe_expiring_urls() { + let timestamp = 1688165560; + let mut msg = CdnUrlMessage::new(); + msg.result = StorageResolveResponse_Result::CDN.into(); + msg.cdnurl = vec![ + format!( + "https://audio-cf.spotifycdn.com/audio/844ecdb297a87ebfee4399f28892ef85d9ba725f?verify={timestamp}-4R3I2w2q7OfNkR%2FGH8qH7xtIKUPlDxywBuADY%2BsvMeU%3D" + ), + format!( + "https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41" + ), + format!( + "https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=" + ), + format!( + "https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=" + ), + "https://audio4-fa.scdn.co/foo?baz".to_string(), + ]; + msg.fileid = vec![0]; + + let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls"); + assert_eq!(urls.len(), 5); + assert!(urls[0].1.is_some()); + assert!(urls[1].1.is_some()); + assert!(urls[2].1.is_some()); + assert!(urls[3].1.is_some()); + assert!(urls[4].1.is_none()); + let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN; + assert_eq!( + urls[0].1.unwrap().as_timestamp_ms() as i128, + timestamp_margin.whole_milliseconds() + ); + } +} diff --git a/core/src/channel.rs b/core/src/channel.rs index 29c3c8aa..dfdc4dc2 100644 --- a/core/src/channel.rs +++ b/core/src/channel.rs @@ -1,16 +1,20 @@ -use std::collections::HashMap; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::Instant; +use std::{ + collections::HashMap, + fmt, + pin::Pin, + task::{Context, Poll}, + time::{Duration, 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::{StreamExt, lock::BiLock, ready}; +use num_traits::FromPrimitive; +use thiserror::Error; use tokio::sync::mpsc; -use crate::util::SeqGenerator; +use crate::{Error, packet::PacketType, util::SeqGenerator}; component! { ChannelManager : ChannelManagerInner { @@ -23,11 +27,23 @@ component! { } } -const ONE_SECOND_IN_MS: usize = 1000; +const ONE_SECOND: Duration = Duration::from_secs(1); -#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] +#[derive(Debug, Error, Hash, PartialEq, Eq, Copy, Clone)] pub struct ChannelError; +impl From for Error { + fn from(err: ChannelError) -> Self { + Error::aborted(err) + } +} + +impl fmt::Display for ChannelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "channel error") + } +} + pub struct Channel { receiver: mpsc::UnboundedReceiver<(u8, Bytes)>, state: ChannelState, @@ -68,7 +84,7 @@ impl ChannelManager { (seq, channel) } - pub(crate) fn dispatch(&self, cmd: u8, mut data: Bytes) { + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { use std::collections::hash_map::Entry; let id: u16 = BigEndian::read_u16(data.split_to(2).as_ref()); @@ -76,10 +92,8 @@ impl ChannelManager { self.lock(|inner| { let current_time = Instant::now(); if let Some(download_measurement_start) = inner.download_measurement_start { - if (current_time - download_measurement_start).as_millis() - > ONE_SECOND_IN_MS as u128 - { - inner.download_rate_estimate = ONE_SECOND_IN_MS + if (current_time - download_measurement_start) > ONE_SECOND { + inner.download_rate_estimate = ONE_SECOND.as_millis() as usize * inner.download_measurement_bytes / (current_time - download_measurement_start).as_millis() as usize; inner.download_measurement_start = Some(current_time); @@ -92,9 +106,14 @@ impl ChannelManager { inner.download_measurement_bytes += data.len(); if let Entry::Occupied(entry) = inner.channels.entry(id) { - let _ = entry.get().send((cmd, data)); + entry + .get() + .send((cmd as u8, data)) + .map_err(|_| ChannelError)?; } - }); + + Ok(()) + }) } pub fn get_download_rate_estimate(&self) -> usize { @@ -114,7 +133,8 @@ impl Channel { fn recv_packet(&mut self, cx: &mut Context<'_>) -> Poll> { let (cmd, packet) = ready!(self.receiver.poll_recv(cx)).ok_or(ChannelError)?; - if cmd == 0xa { + let packet_type = FromPrimitive::from_u8(cmd); + if let Some(PacketType::ChannelError) = packet_type { let code = BigEndian::read_u16(&packet.as_ref()[..2]); error!("channel error: {} {}", packet.len(), code); @@ -139,7 +159,11 @@ impl Stream for Channel { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { loop { match self.state.clone() { - ChannelState::Closed => panic!("Polling already terminated channel"), + ChannelState::Closed => { + error!("Polling already terminated channel"); + return Poll::Ready(None); + } + ChannelState::Header(mut data) => { if data.is_empty() { data = ready!(self.recv_packet(cx))?; @@ -147,7 +171,6 @@ impl Stream for Channel { let length = BigEndian::read_u16(data.split_to(2).as_ref()) as usize; if length == 0 { - assert_eq!(data.len(), 0); self.state = ChannelState::Data; } else { let header_id = data.split_to(1).as_ref()[0]; diff --git a/core/src/component.rs b/core/src/component.rs index a761c455..75387ae5 100644 --- a/core/src/component.rs +++ b/core/src/component.rs @@ -1,3 +1,5 @@ +pub(crate) const COMPONENT_POISON_MSG: &str = "component mutex should not be poisoned"; + macro_rules! component { ($name:ident : $inner:ident { $($key:ident : $ty:ty = $value:expr,)* }) => { #[derive(Clone)] @@ -14,7 +16,8 @@ macro_rules! component { #[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() + .expect($crate::component::COMPONENT_POISON_MSG); f(&mut inner) } diff --git a/core/src/config.rs b/core/src/config.rs index 0e3eaf4a..1b81123c 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,33 +1,70 @@ -use std::fmt; -use std::str::FromStr; +use std::{fmt, path::PathBuf, str::FromStr}; + +use librespot_protocol::devices::DeviceType as ProtoDeviceType; use url::Url; +pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +pub(crate) const ANDROID_CLIENT_ID: &str = "9a8d2f0ce77a4e248bb71fefcb557637"; +pub(crate) const IOS_CLIENT_ID: &str = "58bd3c95768941ea9eb4350aaa033eb3"; + +// Easily adjust the current platform to mock the behavior on it. If for example +// android or ios needs to be mocked, the `os_version` has to be set to a valid version. +// Otherwise, client-token or login5 requests will fail with a generic invalid-credential error. +/// See [std::env::consts::OS] +pub const OS: &str = std::env::consts::OS; + +// valid versions for some os: +// 'android': 30 +// 'ios': 17 +/// See [sysinfo::System::os_version] +pub fn os_version() -> String { + sysinfo::System::os_version().unwrap_or("0".into()) +} + #[derive(Clone, Debug)] pub struct SessionConfig { - pub user_agent: String, + pub client_id: String, pub device_id: String, pub proxy: Option, pub ap_port: Option, + pub tmp_dir: PathBuf, + pub autoplay: Option, } -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(), +impl SessionConfig { + pub(crate) fn default_for_os(os: &str) -> Self { + let device_id = uuid::Uuid::new_v4().as_hyphenated().to_string(); + let client_id = match os { + "android" => ANDROID_CLIENT_ID, + "ios" => IOS_CLIENT_ID, + _ => KEYMASTER_CLIENT_ID, + } + .to_owned(); + + Self { + client_id, device_id, proxy: None, ap_port: None, + tmp_dir: std::env::temp_dir(), + autoplay: None, } } } -#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] +impl Default for SessionConfig { + fn default() -> Self { + Self::default_for_os(OS) + } +} + +#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq, Default)] pub enum DeviceType { Unknown = 0, Computer = 1, Tablet = 2, Smartphone = 3, + #[default] Speaker = 4, Tv = 5, Avr = 6, @@ -42,7 +79,6 @@ pub enum DeviceType { UnknownSpotify = 100, CarThing = 101, Observer = 102, - HomeThing = 103, } impl FromStr for DeviceType { @@ -65,7 +101,6 @@ impl FromStr for DeviceType { "smartwatch" => Ok(Smartwatch), "chromebook" => Ok(Chromebook), "carthing" => Ok(CarThing), - "homething" => Ok(HomeThing), _ => Err(()), } } @@ -93,7 +128,6 @@ impl From<&DeviceType> for &str { UnknownSpotify => "UnknownSpotify", CarThing => "CarThing", Observer => "Observer", - HomeThing => "HomeThing", } } } @@ -105,23 +139,33 @@ impl From for &str { } impl fmt::Display for DeviceType { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let str: &str = self.into(); f.write_str(str) } } -impl Default for DeviceType { - fn default() -> DeviceType { - DeviceType::Speaker +impl From for ProtoDeviceType { + fn from(value: DeviceType) -> Self { + match value { + DeviceType::Unknown => ProtoDeviceType::UNKNOWN, + DeviceType::Computer => ProtoDeviceType::COMPUTER, + DeviceType::Tablet => ProtoDeviceType::TABLET, + DeviceType::Smartphone => ProtoDeviceType::SMARTPHONE, + DeviceType::Speaker => ProtoDeviceType::SPEAKER, + DeviceType::Tv => ProtoDeviceType::TV, + DeviceType::Avr => ProtoDeviceType::AVR, + DeviceType::Stb => ProtoDeviceType::STB, + DeviceType::AudioDongle => ProtoDeviceType::AUDIO_DONGLE, + DeviceType::GameConsole => ProtoDeviceType::GAME_CONSOLE, + DeviceType::CastAudio => ProtoDeviceType::CAST_VIDEO, + DeviceType::CastVideo => ProtoDeviceType::CAST_AUDIO, + DeviceType::Automobile => ProtoDeviceType::AUTOMOBILE, + DeviceType::Smartwatch => ProtoDeviceType::SMARTWATCH, + DeviceType::Chromebook => ProtoDeviceType::CHROMEBOOK, + DeviceType::UnknownSpotify => ProtoDeviceType::UNKNOWN_SPOTIFY, + DeviceType::CarThing => ProtoDeviceType::CAR_THING, + DeviceType::Observer => ProtoDeviceType::OBSERVER, + } } } - -#[derive(Clone, Debug)] -pub struct ConnectConfig { - pub name: String, - pub device_type: DeviceType, - pub initial_volume: Option, - pub has_volume_ctrl: bool, - pub autoplay: bool, -} 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 eddcd327..1dea18ac 100644 --- a/core/src/connection/handshake.rs +++ b/core/src/connection/handshake.rs @@ -1,33 +1,101 @@ +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 hmac::{Hmac, Mac}; +use protobuf::Message; +use rand::RngCore; +use rsa::{BigUint, Pkcs1v15Sign, RsaPublicKey}; +use sha1::{Digest, Sha1}; +use thiserror::Error; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio_util::codec::{Decoder, Framed}; use super::codec::ApCodec; -use crate::diffie_hellman::DhLocalKeys; + +use crate::{diffie_hellman::DhLocalKeys, version}; + use crate::protocol; -use crate::protocol::keyexchange::{APResponseMessage, ClientHello, ClientResponsePlaintext}; +use crate::protocol::keyexchange::{ + APResponseMessage, ClientHello, ClientResponsePlaintext, Platform, ProductFlags, +}; + +const SERVER_KEY: [u8; 256] = [ + 0xac, 0xe0, 0x46, 0x0b, 0xff, 0xc2, 0x30, 0xaf, 0xf4, 0x6b, 0xfe, 0xc3, 0xbf, 0xbf, 0x86, 0x3d, + 0xa1, 0x91, 0xc6, 0xcc, 0x33, 0x6c, 0x93, 0xa1, 0x4f, 0xb3, 0xb0, 0x16, 0x12, 0xac, 0xac, 0x6a, + 0xf1, 0x80, 0xe7, 0xf6, 0x14, 0xd9, 0x42, 0x9d, 0xbe, 0x2e, 0x34, 0x66, 0x43, 0xe3, 0x62, 0xd2, + 0x32, 0x7a, 0x1a, 0x0d, 0x92, 0x3b, 0xae, 0xdd, 0x14, 0x02, 0xb1, 0x81, 0x55, 0x05, 0x61, 0x04, + 0xd5, 0x2c, 0x96, 0xa4, 0x4c, 0x1e, 0xcc, 0x02, 0x4a, 0xd4, 0xb2, 0x0c, 0x00, 0x1f, 0x17, 0xed, + 0xc2, 0x2f, 0xc4, 0x35, 0x21, 0xc8, 0xf0, 0xcb, 0xae, 0xd2, 0xad, 0xd7, 0x2b, 0x0f, 0x9d, 0xb3, + 0xc5, 0x32, 0x1a, 0x2a, 0xfe, 0x59, 0xf3, 0x5a, 0x0d, 0xac, 0x68, 0xf1, 0xfa, 0x62, 0x1e, 0xfb, + 0x2c, 0x8d, 0x0c, 0xb7, 0x39, 0x2d, 0x92, 0x47, 0xe3, 0xd7, 0x35, 0x1a, 0x6d, 0xbd, 0x24, 0xc2, + 0xae, 0x25, 0x5b, 0x88, 0xff, 0xab, 0x73, 0x29, 0x8a, 0x0b, 0xcc, 0xcd, 0x0c, 0x58, 0x67, 0x31, + 0x89, 0xe8, 0xbd, 0x34, 0x80, 0x78, 0x4a, 0x5f, 0xc9, 0x6b, 0x89, 0x9d, 0x95, 0x6b, 0xfc, 0x86, + 0xd7, 0x4f, 0x33, 0xa6, 0x78, 0x17, 0x96, 0xc9, 0xc3, 0x2d, 0x0d, 0x32, 0xa5, 0xab, 0xcd, 0x05, + 0x27, 0xe2, 0xf7, 0x10, 0xa3, 0x96, 0x13, 0xc4, 0x2f, 0x99, 0xc0, 0x27, 0xbf, 0xed, 0x04, 0x9c, + 0x3c, 0x27, 0x58, 0x04, 0xb6, 0xb2, 0x19, 0xf9, 0xc1, 0x2f, 0x02, 0xe9, 0x48, 0x63, 0xec, 0xa1, + 0xb6, 0x42, 0xa0, 0x9d, 0x48, 0x25, 0xf8, 0xb3, 0x9d, 0xd0, 0xe8, 0x6a, 0xf9, 0x48, 0x4d, 0xa1, + 0xc2, 0xba, 0x86, 0x30, 0x42, 0xea, 0x9d, 0xb3, 0x08, 0x6c, 0x19, 0x0e, 0x48, 0xb3, 0x9d, 0x66, + 0xeb, 0x00, 0x06, 0xa2, 0x5a, 0xee, 0xa1, 0x1b, 0x13, 0x87, 0x3c, 0xd7, 0x19, 0xe6, 0x55, 0xbd, +]; + +#[derive(Debug, Error)] +pub enum HandshakeError { + #[error("invalid key length")] + InvalidLength, + #[error("server key verification failed")] + VerificationFailed, +} pub async fn handshake( mut connection: T, ) -> io::Result> { - let local_keys = DhLocalKeys::random(&mut thread_rng()); + let local_keys = DhLocalKeys::random(&mut rand::rng()); let gc = local_keys.public_key(); let mut accumulator = client_hello(&mut connection, gc).await?; let message: APResponseMessage = recv_packet(&mut connection, &mut accumulator).await?; let remote_key = message - .get_challenge() - .get_login_crypto_challenge() - .get_diffie_hellman() - .get_gs() + .challenge + .get_or_default() + .login_crypto_challenge + .get_or_default() + .diffie_hellman + .get_or_default() + .gs() + .to_owned(); + let remote_signature = message + .challenge + .get_or_default() + .login_crypto_challenge + .get_or_default() + .diffie_hellman + .get_or_default() + .gs_signature() .to_owned(); + // Prevent man-in-the-middle attacks: check server signature + let n = BigUint::from_bytes_be(&SERVER_KEY); + let e = BigUint::new(vec![65537]); + let public_key = RsaPublicKey::new(n, e).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + HandshakeError::VerificationFailed, + ) + })?; + + let hash = Sha1::digest(&remote_key); + let padding = Pkcs1v15Sign::new::(); + public_key + .verify(padding, &hash, &remote_signature) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + HandshakeError::VerificationFailed, + ) + })?; + + // OK to proceed let shared_secret = local_keys.shared_secret(&remote_key); - let (challenge, send_key, recv_key) = compute_keys(&shared_secret, &accumulator); + let (challenge, send_key, recv_key) = compute_keys(&shared_secret, &accumulator)?; let codec = ApCodec::new(&send_key, &recv_key); client_response(&mut connection, challenge).await?; @@ -40,34 +108,90 @@ where T: AsyncWrite + Unpin, { let mut client_nonce = vec![0; 0x10]; - thread_rng().fill_bytes(&mut client_nonce); + rand::rng().fill_bytes(&mut client_nonce); + + let platform = match crate::config::OS { + "freebsd" | "netbsd" | "openbsd" => match ARCH { + "x86_64" => Platform::PLATFORM_FREEBSD_X86_64, + _ => Platform::PLATFORM_FREEBSD_X86, + }, + "ios" => match ARCH { + "aarch64" => Platform::PLATFORM_IPHONE_ARM64, + _ => Platform::PLATFORM_IPHONE_ARM, + }, + // Rather than sending `Platform::PLATFORM_ANDROID_ARM` for "android", + // we are spoofing "android" as "linux", as otherwise during Session::connect + // all APs will reject the client with TryAnotherAP, no matter the credentials + // used was obtained via OAuth using KEYMASTER or ANDROID's client ID or + // Login5Manager::login + "linux" | "android" => match ARCH { + "arm" | "aarch64" => Platform::PLATFORM_LINUX_ARM, + "blackfin" => Platform::PLATFORM_LINUX_BLACKFIN, + "mips" => Platform::PLATFORM_LINUX_MIPS, + "sh" => Platform::PLATFORM_LINUX_SH, + "x86_64" => Platform::PLATFORM_LINUX_X86_64, + _ => Platform::PLATFORM_LINUX_X86, + }, + "macos" => match ARCH { + "ppc" | "ppc64" => Platform::PLATFORM_OSX_PPC, + "x86_64" => Platform::PLATFORM_OSX_X86_64, + _ => Platform::PLATFORM_OSX_X86, + }, + "windows" => match ARCH { + "arm" | "aarch64" => Platform::PLATFORM_WINDOWS_CE_ARM, + "x86_64" => Platform::PLATFORM_WIN32_X86_64, + _ => Platform::PLATFORM_WIN32_X86, + }, + _ => Platform::PLATFORM_LINUX_X86, + }; + + #[cfg(debug_assertions)] + const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_DEV_BUILD; + #[cfg(not(debug_assertions))] + const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_NONE; let mut packet = ClientHello::new(); packet - .mut_build_info() - .set_product(protocol::keyexchange::Product::PRODUCT_PARTNER); + .build_info + .mut_or_insert_default() + // 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); + .build_info + .mut_or_insert_default() + .product_flags + .push(PRODUCT_FLAGS.into()); packet - .mut_cryptosuites_supported() - .push(protocol::keyexchange::Cryptosuite::CRYPTO_SUITE_SHANNON); + .build_info + .mut_or_insert_default() + .set_platform(platform); packet - .mut_login_crypto_hello() - .mut_diffie_hellman() + .build_info + .mut_or_insert_default() + .set_version(version::SPOTIFY_VERSION); + packet + .cryptosuites_supported + .push(protocol::keyexchange::Cryptosuite::CRYPTO_SUITE_SHANNON.into()); + packet + .login_crypto_hello + .mut_or_insert_default() + .diffie_hellman + .mut_or_insert_default() .set_gc(gc); packet - .mut_login_crypto_hello() - .mut_diffie_hellman() + .login_crypto_hello + .mut_or_insert_default() + .diffie_hellman + .mut_or_insert_default() .set_server_keys_known(1); packet.set_client_nonce(client_nonce); packet.set_padding(vec![0x1e]); 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.try_into().unwrap())?; + packet.write_to_vec(&mut buffer)?; connection.write_all(&buffer[..]).await?; Ok(buffer) @@ -79,16 +203,19 @@ where { let mut packet = ClientResponsePlaintext::new(); packet - .mut_login_crypto_response() - .mut_diffie_hellman() + .login_crypto_response + .mut_or_insert_default() + .diffie_hellman + .mut_or_insert_default() .set_hmac(challenge); - packet.mut_pow_response(); - packet.mut_crypto_response(); + + packet.pow_response.mut_or_insert_default(); + packet.crypto_response.mut_or_insert_default(); 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.try_into().unwrap())?; + packet.write_to_vec(&mut buffer)?; connection.write_all(&buffer[..]).await?; Ok(()) @@ -102,12 +229,12 @@ 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) } -async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>( - connection: &'a mut T, +async fn read_into_accumulator<'b, T: AsyncRead + Unpin>( + connection: &mut T, size: usize, acc: &'b mut Vec, ) -> io::Result<&'b mut [u8]> { @@ -118,24 +245,26 @@ async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>( Ok(&mut acc[offset..]) } -fn compute_keys(shared_secret: &[u8], packets: &[u8]) -> (Vec, Vec, Vec) { +fn compute_keys(shared_secret: &[u8], packets: &[u8]) -> io::Result<(Vec, Vec, Vec)> { type HmacSha1 = Hmac; let mut data = Vec::with_capacity(0x64); for i in 1..6 { - let mut mac = - HmacSha1::new_from_slice(shared_secret).expect("HMAC can take key of any size"); + let mut mac = HmacSha1::new_from_slice(shared_secret).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength) + })?; mac.update(packets); mac.update(&[i]); data.extend_from_slice(&mac.finalize().into_bytes()); } - let mut mac = HmacSha1::new_from_slice(&data[..0x14]).expect("HMAC can take key of any size"); + let mut mac = HmacSha1::new_from_slice(&data[..0x14]) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength))?; mac.update(packets); - ( + Ok(( mac.finalize().into_bytes().to_vec(), data[0x14..0x34].to_vec(), data[0x34..0x54].to_vec(), - ) + )) } diff --git a/core/src/connection/mod.rs b/core/src/connection/mod.rs index 58d3e83a..83017189 100644 --- a/core/src/connection/mod.rs +++ b/core/src/connection/mod.rs @@ -1,23 +1,21 @@ mod codec; mod handshake; -pub use self::codec::ApCodec; -pub use self::handshake::handshake; +pub use self::{codec::ApCodec, handshake::handshake}; -use std::io::{self, ErrorKind}; -use std::net::ToSocketAddrs; +use std::{io, time::Duration}; use futures_util::{SinkExt, StreamExt}; -use protobuf::{self, Message, ProtobufError}; +use num_traits::FromPrimitive; +use protobuf::Message; use thiserror::Error; use tokio::net::TcpStream; use tokio_util::codec::Framed; use url::Url; -use crate::authentication::Credentials; +use crate::{Error, authentication::Credentials, packet::PacketType, version}; + use crate::protocol::keyexchange::{APLoginFailed, ErrorCode}; -use crate::proxytunnel; -use crate::version; pub type Transport = Framed; @@ -25,8 +23,8 @@ fn login_error_message(code: &ErrorCode) -> &'static str { pub use ErrorCode::*; match code { ProtocolError => "Protocol error", - TryAnotherAP => "Try another AP", - BadConnectionId => "Bad connection id", + TryAnotherAP => "Try another access point", + BadConnectionId => "Bad connection ID", TravelRestriction => "Travel restriction", PremiumAccountRequired => "Premium account required", BadCredentials => "Bad credentials", @@ -42,127 +40,155 @@ 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), + } } } impl From for AuthenticationError { fn from(login_failure: APLoginFailed) -> Self { - Self::LoginFailed(login_failure.get_error_code()) + Self::LoginFailed(login_failure.error_code()) } } -pub async fn connect(addr: String, proxy: Option<&Url>) -> io::Result { - let socket = if let Some(proxy_url) = proxy { - info!("Using proxy \"{}\"", proxy_url); +pub async fn connect(host: &str, port: u16, proxy: Option<&Url>) -> io::Result { + const TIMEOUT: Duration = Duration::from_secs(5); + tokio::time::timeout(TIMEOUT, { + let socket = crate::socket::connect(host, port, proxy).await?; + debug!("Connection to AP established."); + handshake(socket) + }) + .await? +} - let socket_addr = proxy_url.socket_addrs(|| None).and_then(|addrs| { - addrs.into_iter().next().ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "Can't resolve proxy server address", - ) - }) - })?; - let socket = TcpStream::connect(&socket_addr).await?; - - let uri = addr.parse::().map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidData, - "Can't parse access point address", - ) - })?; - let host = uri.host().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "The access point address contains no hostname", - ) - })?; - let port = uri.port().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "The access point address contains no port", - ) - })?; - - proxytunnel::proxy_connect(socket, host, port.as_str()).await? - } else { - let socket_addr = addr.to_socket_addrs()?.next().ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "Can't resolve access point address", - ) - })?; - - TcpStream::connect(&socket_addr).await? - }; - - handshake(socket).await +pub async fn connect_with_retry( + host: &str, + port: u16, + proxy: Option<&Url>, + max_retries: u8, +) -> io::Result { + let mut num_retries = 0; + loop { + match connect(host, port, proxy).await { + Ok(f) => return Ok(f), + Err(e) => { + debug!("Connection to \"{host}:{port}\" failed: {e}"); + if num_retries < max_retries { + num_retries += 1; + debug!("Retry access point..."); + continue; + } + return Err(e); + } + } + } } 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" | "aarch64" => 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 crate::config::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(); + if let Some(username) = credentials.username { + packet + .login_credentials + .mut_or_insert_default() + .set_username(username); + } packet - .mut_login_credentials() - .set_username(credentials.username); - packet - .mut_login_credentials() + .login_credentials + .mut_or_insert_default() .set_typ(credentials.auth_type); packet - .mut_login_credentials() + .login_credentials + .mut_or_insert_default() .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); + .system_info + .mut_or_insert_default() + .set_cpu_family(cpu_family); + packet.system_info.mut_or_insert_default().set_os(os); packet - .mut_system_info() + .system_info + .mut_or_insert_default() .set_system_information_string(format!( - "librespot_{}_{}", + "librespot-{}-{}", version::SHA_SHORT, version::BUILD_ID )); packet - .mut_system_info() + .system_info + .mut_or_insert_default() .set_device_id(device_id.to_string()); - packet.set_version_string(version::VERSION_STRING.to_string()); + packet.set_version_string(format!("librespot {}", version::SEMVER)); - let cmd = 0xab; - let data = packet.write_to_bytes().unwrap(); + let cmd = PacketType::Login; + let data = packet.write_to_bytes()?; - transport.send((cmd, data)).await?; - let (cmd, data) = transport.next().await.expect("EOF")?; - match cmd { - 0xac => { + debug!("Authenticating with AP using {:?}", credentials.auth_type); + transport.send((cmd as u8, data)).await?; + let (cmd, data) = transport + .next() + .await + .ok_or(AuthenticationError::Transport)??; + let packet_type = FromPrimitive::from_u8(cmd); + let result = match packet_type { + Some(PacketType::APWelcome) => { let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?; let reusable_credentials = Credentials { - username: welcome_data.get_canonical_username().to_owned(), - auth_type: welcome_data.get_reusable_auth_credentials_type(), - auth_data: welcome_data.get_reusable_auth_credentials().to_owned(), + username: Some(welcome_data.canonical_username().to_owned()), + auth_type: welcome_data.reusable_auth_credentials_type(), + auth_data: welcome_data.reusable_auth_credentials().to_owned(), }; Ok(reusable_credentials) } - 0xad => { + Some(PacketType::AuthFailure) => { let error_data = APLoginFailed::parse_from_bytes(data.as_ref())?; Err(error_data.into()) } _ => { - let msg = format!("Received invalid packet: {}", cmd); - Err(io::Error::new(ErrorKind::InvalidData, msg).into()) + trace!("Did not expect {cmd:?} AES key packet with data {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..e77cf5ef --- /dev/null +++ b/core/src/date.rs @@ -0,0 +1,81 @@ +use std::{fmt::Debug, ops::Deref}; + +use time::{ + Date as _Date, OffsetDateTime, PrimitiveDateTime, Time, error::ComponentRange, + format_description::well_known::Iso8601, +}; + +use crate::Error; + +use librespot_protocol as protocol; +use protocol::metadata::Date as DateMessage; + +impl From for Error { + fn from(err: ComponentRange) -> Self { + Error::out_of_range(err) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Date(pub OffsetDateTime); + +impl Deref for Date { + type Target = OffsetDateTime; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Date { + pub fn as_timestamp_ms(&self) -> i64 { + (self.0.unix_timestamp_nanos() / 1_000_000) as i64 + } + + pub fn from_timestamp_ms(timestamp: i64) -> Result { + let date_time = OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128 * 1_000_000)?; + Ok(Self(date_time)) + } + + pub fn as_utc(&self) -> OffsetDateTime { + self.0 + } + + pub fn from_utc(date_time: PrimitiveDateTime) -> Self { + Self(date_time.assume_utc()) + } + + pub fn now_utc() -> Self { + Self(OffsetDateTime::now_utc()) + } + + pub fn from_iso8601(input: &str) -> Result { + let date_time = OffsetDateTime::parse(input, &Iso8601::DEFAULT)?; + Ok(Self(date_time)) + } +} + +impl TryFrom<&DateMessage> for Date { + type Error = crate::Error; + fn try_from(msg: &DateMessage) -> Result { + // Some metadata contains a year, but no month. In that case just set January. + let month = if msg.has_month() { + msg.month() as u8 + } else { + 1 + }; + + // Having no day will work, but may be unexpected: it will imply the last day + // of the month before. So prevent that, and just set day 1. + let day = if msg.has_day() { msg.day() as u8 } else { 1 }; + + let date = _Date::from_calendar_date(msg.year(), month.try_into()?, day)?; + let time = Time::from_hms(msg.hour() as u8, msg.minute() as u8, 0)?; + Ok(Self::from_utc(PrimitiveDateTime::new(date, time))) + } +} + +impl From for Date { + fn from(datetime: OffsetDateTime) -> Self { + Self(datetime) + } +} diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs new file mode 100644 index 00000000..98ea0265 --- /dev/null +++ b/core/src/dealer/manager.rs @@ -0,0 +1,174 @@ +use futures_core::Stream; +use futures_util::StreamExt; +use std::{pin::Pin, str::FromStr, sync::OnceLock}; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; +use url::Url; + +use super::{ + Builder, Dealer, GetUrlResult, Request, RequestHandler, Responder, Response, Subscription, + protocol::Message, +}; +use crate::{Error, Session}; + +component! { + DealerManager: DealerManagerInner { + builder: OnceLock = OnceLock::from(Builder::new()), + dealer: OnceLock = OnceLock::new(), + } +} + +pub type BoxedStream = Pin + Send>>; +pub type BoxedStreamResult = BoxedStream>; + +#[derive(Error, Debug)] +enum DealerError { + #[error("Builder wasn't available")] + BuilderNotAvailable, + #[error("Websocket couldn't be started because: {0}")] + LaunchFailure(Error), + #[error("Failed to set dealer")] + CouldNotSetDealer, +} + +impl From for Error { + fn from(err: DealerError) -> Self { + Error::failed_precondition(err) + } +} + +#[derive(Debug)] +pub enum Reply { + Success, + Failure, + Unanswered, +} + +pub type RequestReply = (Request, mpsc::UnboundedSender); +type RequestReceiver = mpsc::UnboundedReceiver; +type RequestSender = mpsc::UnboundedSender; + +struct DealerRequestHandler(RequestSender); + +impl DealerRequestHandler { + pub fn new() -> (Self, RequestReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (DealerRequestHandler(tx), rx) + } +} + +impl RequestHandler for DealerRequestHandler { + fn handle_request(&self, request: Request, responder: Responder) { + let (tx, mut rx) = mpsc::unbounded_channel(); + + if let Err(why) = self.0.send((request, tx)) { + error!("failed sending dealer request {why}"); + responder.send(Response { success: false }); + return; + } + + tokio::spawn(async move { + let reply = rx.recv().await.unwrap_or(Reply::Failure); + debug!("replying to ws request: {reply:?}"); + match reply { + Reply::Unanswered => responder.force_unanswered(), + Reply::Success | Reply::Failure => responder.send(Response { + success: matches!(reply, Reply::Success), + }), + } + }); + } +} + +impl DealerManager { + async fn get_url(session: Session) -> GetUrlResult { + let (host, port) = session.apresolver().resolve("dealer").await?; + let token = session.login5().auth_token().await?.access_token; + let url = format!("wss://{host}:{port}/?access_token={token}"); + let url = Url::from_str(&url)?; + Ok(url) + } + + pub fn add_listen_for(&self, url: impl Into) -> Result { + let url = url.into(); + self.lock(|inner| { + if let Some(dealer) = inner.dealer.get() { + dealer.subscribe(&[&url]) + } else if let Some(builder) = inner.builder.get_mut() { + builder.subscribe(&[&url]) + } else { + Err(DealerError::BuilderNotAvailable.into()) + } + }) + } + + pub fn listen_for( + &self, + uri: impl Into, + t: impl Fn(Message) -> Result + Send + 'static, + ) -> Result, Error> { + Ok(Box::pin(self.add_listen_for(uri)?.map(t))) + } + + pub fn add_handle_for(&self, url: impl Into) -> Result { + let url = url.into(); + + let (handler, receiver) = DealerRequestHandler::new(); + self.lock(|inner| { + if let Some(dealer) = inner.dealer.get() { + dealer.add_handler(&url, handler).map(|_| receiver) + } else if let Some(builder) = inner.builder.get_mut() { + builder.add_handler(&url, handler).map(|_| receiver) + } else { + Err(DealerError::BuilderNotAvailable.into()) + } + }) + } + + pub fn handle_for(&self, uri: impl Into) -> Result, Error> { + Ok(Box::pin( + self.add_handle_for(uri).map(UnboundedReceiverStream::new)?, + )) + } + + pub fn handles(&self, uri: &str) -> bool { + self.lock(|inner| { + if let Some(dealer) = inner.dealer.get() { + dealer.handles(uri) + } else if let Some(builder) = inner.builder.get() { + builder.handles(uri) + } else { + false + } + }) + } + + pub async fn start(&self) -> Result<(), Error> { + debug!("Launching dealer"); + + let session = self.session(); + // the url has to be a function that can retrieve a new url, + // otherwise when we later try to reconnect with the initial url/token + // and the token is expired we will just get 401 error + let get_url = move || Self::get_url(session.clone()); + + let dealer = self + .lock(move |inner| inner.builder.take()) + .ok_or(DealerError::BuilderNotAvailable)? + .launch(get_url, None) + .await + .map_err(DealerError::LaunchFailure)?; + + self.lock(|inner| inner.dealer.set(dealer)) + .map_err(|_| DealerError::CouldNotSetDealer)?; + + Ok(()) + } + + pub async fn close(&self) { + if let Some(dealer) = self.lock(|inner| inner.dealer.take()) { + dealer.close().await + } + } +} diff --git a/core/src/dealer/maps.rs b/core/src/dealer/maps.rs new file mode 100644 index 00000000..23d21a11 --- /dev/null +++ b/core/src/dealer/maps.rs @@ -0,0 +1,155 @@ +use std::collections::HashMap; + +use crate::Error; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum HandlerMapError { + #[error("request was already handled")] + AlreadyHandled, +} + +impl From for Error { + fn from(err: HandlerMapError) -> Self { + Error::aborted(err) + } +} + +pub enum HandlerMap { + Leaf(T), + Branch(HashMap>), +} + +impl Default for HandlerMap { + fn default() -> Self { + Self::Branch(HashMap::new()) + } +} + +impl HandlerMap { + pub fn contains(&self, path: &str) -> bool { + matches!(self, HandlerMap::Branch(map) if map.contains_key(path)) + } + + pub fn insert<'a>( + &mut self, + mut path: impl Iterator, + handler: T, + ) -> Result<(), Error> { + match self { + Self::Leaf(_) => Err(HandlerMapError::AlreadyHandled.into()), + Self::Branch(children) => { + if let Some(component) = path.next() { + let node = children.entry(component.to_owned()).or_default(); + node.insert(path, handler) + } else if children.is_empty() { + *self = Self::Leaf(handler); + Ok(()) + } else { + Err(HandlerMapError::AlreadyHandled.into()) + } + } + } + } + + pub fn get<'a>(&self, mut path: impl Iterator) -> Option<&T> { + match self { + Self::Leaf(t) => Some(t), + Self::Branch(m) => { + let component = path.next()?; + m.get(component)?.get(path) + } + } + } + + pub fn remove<'a>(&mut self, mut path: impl Iterator) -> Option { + match self { + Self::Leaf(_) => match std::mem::take(self) { + Self::Leaf(t) => Some(t), + _ => unreachable!(), + }, + Self::Branch(map) => { + let component = path.next()?; + let next = map.get_mut(component)?; + let result = next.remove(path); + match &*next { + Self::Branch(b) if b.is_empty() => { + map.remove(component); + } + _ => (), + } + result + } + } + } +} + +pub struct SubscriberMap { + subscribed: Vec, + children: HashMap>, +} + +impl Default for SubscriberMap { + fn default() -> Self { + Self { + subscribed: Vec::new(), + children: HashMap::new(), + } + } +} + +impl SubscriberMap { + pub fn insert<'a>(&mut self, mut path: impl Iterator, handler: T) { + if let Some(component) = path.next() { + self.children + .entry(component.to_owned()) + .or_default() + .insert(path, handler); + } else { + self.subscribed.push(handler); + } + } + + pub fn contains<'a>(&self, mut path: impl Iterator) -> bool { + if !self.subscribed.is_empty() { + return true; + } + + if let Some(next) = path.next() { + if let Some(next_map) = self.children.get(next) { + return next_map.contains(path); + } + } else { + return !self.is_empty(); + } + + false + } + + pub fn is_empty(&self) -> bool { + self.children.is_empty() && self.subscribed.is_empty() + } + + pub fn retain<'a>( + &mut self, + mut path: impl Iterator, + fun: &mut impl FnMut(&T) -> bool, + ) -> bool { + let mut handled_by_any = false; + self.subscribed.retain(|x| { + handled_by_any = true; + fun(x) + }); + + if let Some(next) = path.next() { + if let Some(y) = self.children.get_mut(next) { + handled_by_any = handled_by_any || y.retain(path, fun); + if y.is_empty() { + self.children.remove(next); + } + } + } + + handled_by_any + } +} diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs new file mode 100644 index 00000000..63ee6e72 --- /dev/null +++ b/core/src/dealer/mod.rs @@ -0,0 +1,724 @@ +pub mod manager; +mod maps; +pub mod protocol; + +use std::{ + iter, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{self, AtomicBool}, + }, + task::Poll, + time::Duration, +}; + +use futures_core::{Future, Stream}; +use futures_util::{SinkExt, StreamExt, future::join_all}; +use thiserror::Error; +use tokio::{ + select, + sync::{ + Semaphore, + mpsc::{self, UnboundedReceiver}, + }, + task::JoinHandle, +}; +use tokio_tungstenite::tungstenite; +use tungstenite::error::UrlError; +use url::Url; + +use self::{ + maps::*, + protocol::{Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest}, +}; + +use crate::{ + Error, socket, + util::{CancelOnDrop, TimeoutOnDrop, keep_flushing}, +}; + +type WsMessage = tungstenite::Message; +type WsError = tungstenite::Error; +type WsResult = Result; +type GetUrlResult = Result; + +impl From for Error { + fn from(err: WsError) -> Self { + Error::failed_precondition(err) + } +} + +const WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(3); + +const PING_INTERVAL: Duration = Duration::from_secs(30); +const PING_TIMEOUT: Duration = Duration::from_secs(3); + +const RECONNECT_INTERVAL: Duration = Duration::from_secs(10); + +const DEALER_REQUEST_HANDLERS_POISON_MSG: &str = + "dealer request handlers mutex should not be poisoned"; +const DEALER_MESSAGE_HANDLERS_POISON_MSG: &str = + "dealer message handlers mutex should not be poisoned"; + +struct Response { + pub success: bool, +} + +struct Responder { + key: String, + tx: mpsc::UnboundedSender, + sent: bool, +} + +impl Responder { + fn new(key: String, tx: mpsc::UnboundedSender) -> Self { + Self { + key, + tx, + sent: false, + } + } + + // Should only be called once + fn send_internal(&mut self, response: Response) { + let response = serde_json::json!({ + "type": "reply", + "key": &self.key, + "payload": { + "success": response.success, + } + }) + .to_string(); + + if let Err(e) = self.tx.send(WsMessage::Text(response.into())) { + warn!("Wasn't able to reply to dealer request: {e}"); + } + } + + pub fn send(mut self, response: Response) { + self.send_internal(response); + self.sent = true; + } + + pub fn force_unanswered(mut self) { + self.sent = true; + } +} + +impl Drop for Responder { + fn drop(&mut self) { + if !self.sent { + self.send_internal(Response { success: false }); + } + } +} + +trait IntoResponse { + fn respond(self, responder: Responder); +} + +impl IntoResponse for Response { + fn respond(self, responder: Responder) { + responder.send(self) + } +} + +impl IntoResponse for F +where + F: Future + Send + 'static, +{ + fn respond(self, responder: Responder) { + tokio::spawn(async move { + responder.send(self.await); + }); + } +} + +impl RequestHandler for F +where + F: (Fn(Request) -> R) + Send + 'static, + R: IntoResponse, +{ + fn handle_request(&self, request: Request, responder: Responder) { + self(request).respond(responder); + } +} + +trait RequestHandler: Send + 'static { + fn handle_request(&self, request: Request, responder: Responder); +} + +type MessageHandler = mpsc::UnboundedSender; + +// TODO: Maybe it's possible to unregister subscription directly when they +// are dropped instead of on next failed attempt. +pub struct Subscription(UnboundedReceiver); + +impl Stream for Subscription { + type Item = Message; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + self.0.poll_recv(cx) + } +} + +fn split_uri(s: &str) -> Option> { + let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix("hm://") { + ("hm", '/', rest) + } else if let Some(rest) = s.strip_prefix("spotify:") { + ("spotify", ':', rest) + } else if s.contains('/') { + ("", '/', s) + } else { + return None; + }; + + let rest = rest.trim_end_matches(sep); + let split = rest.split(sep); + + Some(iter::once(scheme).chain(split)) +} + +#[derive(Debug, Clone, Error)] +enum AddHandlerError { + #[error("There is already a handler for the given uri")] + AlreadyHandled, + #[error("The specified uri {0} is invalid")] + InvalidUri(String), +} + +impl From for Error { + fn from(err: AddHandlerError) -> Self { + match err { + AddHandlerError::AlreadyHandled => Error::aborted(err), + AddHandlerError::InvalidUri(_) => Error::invalid_argument(err), + } + } +} + +#[derive(Debug, Clone, Error)] +enum SubscriptionError { + #[error("The specified uri is invalid")] + InvalidUri(String), +} + +impl From for Error { + fn from(err: SubscriptionError) -> Self { + Error::invalid_argument(err) + } +} + +fn add_handler( + map: &mut HandlerMap>, + uri: &str, + handler: impl RequestHandler, +) -> Result<(), Error> { + let split = split_uri(uri).ok_or_else(|| AddHandlerError::InvalidUri(uri.to_string()))?; + map.insert(split, Box::new(handler)) +} + +fn remove_handler(map: &mut HandlerMap, uri: &str) -> Option { + map.remove(split_uri(uri)?) +} + +fn subscribe( + map: &mut SubscriberMap, + uris: &[&str], +) -> Result { + let (tx, rx) = mpsc::unbounded_channel(); + + for &uri in uris { + let split = split_uri(uri).ok_or_else(|| SubscriptionError::InvalidUri(uri.to_string()))?; + map.insert(split, tx.clone()); + } + + Ok(Subscription(rx)) +} + +fn handles( + req_map: &HandlerMap>, + msg_map: &SubscriberMap, + uri: &str, +) -> bool { + if req_map.contains(uri) { + return true; + } + + match split_uri(uri) { + None => false, + Some(mut split) => msg_map.contains(&mut split), + } +} + +#[derive(Default)] +struct Builder { + message_handlers: SubscriberMap, + request_handlers: HandlerMap>, +} + +macro_rules! create_dealer { + ($builder:expr, $shared:ident -> $body:expr) => { + match $builder { + builder => { + let shared = Arc::new(DealerShared { + message_handlers: Mutex::new(builder.message_handlers), + request_handlers: Mutex::new(builder.request_handlers), + notify_drop: Semaphore::new(0), + }); + + let handle = { + let $shared = Arc::clone(&shared); + tokio::spawn($body) + }; + + Dealer { + shared, + handle: TimeoutOnDrop::new(handle, WEBSOCKET_CLOSE_TIMEOUT), + } + } + } + }; +} + +impl Builder { + pub fn new() -> Self { + Self::default() + } + + pub fn add_handler(&mut self, uri: &str, handler: impl RequestHandler) -> Result<(), Error> { + add_handler(&mut self.request_handlers, uri, handler) + } + + pub fn subscribe(&mut self, uris: &[&str]) -> Result { + subscribe(&mut self.message_handlers, uris) + } + + pub fn handles(&self, uri: &str) -> bool { + handles(&self.request_handlers, &self.message_handlers, uri) + } + + pub fn launch_in_background(self, get_url: F, proxy: Option) -> Dealer + where + Fut: Future + Send + 'static, + F: (Fn() -> Fut) + Send + 'static, + { + create_dealer!(self, shared -> run(shared, None, get_url, proxy)) + } + + pub async fn launch(self, get_url: F, proxy: Option) -> WsResult + where + Fut: Future + Send + 'static, + F: (Fn() -> Fut) + Send + 'static, + { + let dealer = create_dealer!(self, shared -> { + // Try to connect. + let url = get_url().await?; + let tasks = connect(&url, proxy.as_ref(), &shared).await?; + + // If a connection is established, continue in a background task. + run(shared, Some(tasks), get_url, proxy) + }); + + Ok(dealer) + } +} + +struct DealerShared { + message_handlers: Mutex>, + request_handlers: Mutex>>, + + // Semaphore with 0 permits. By closing this semaphore, we indicate + // that the actual Dealer struct has been dropped. + notify_drop: Semaphore, +} + +impl DealerShared { + fn dispatch_message(&self, mut msg: WebsocketMessage) { + let msg = match msg.handle_payload() { + Ok(value) => Message { + headers: msg.headers, + payload: value, + uri: msg.uri, + }, + Err(why) => { + warn!("failure during data parsing for {}: {why}", msg.uri); + return; + } + }; + + if let Some(split) = split_uri(&msg.uri) { + if self + .message_handlers + .lock() + .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG) + .retain(split, &mut |tx| tx.send(msg.clone()).is_ok()) + { + return; + } + } + + debug!("No subscriber for msg.uri: {}", msg.uri); + } + + fn dispatch_request( + &self, + request: WebsocketRequest, + send_tx: &mpsc::UnboundedSender, + ) { + trace!("dealer request {}", &request.message_ident); + + let payload_request = match request.handle_payload() { + Ok(payload) => payload, + Err(why) => { + warn!("request payload handling failed because of {why}"); + return; + } + }; + + // ResponseSender will automatically send "success: false" if it is dropped without an answer. + let responder = Responder::new(request.key.clone(), send_tx.clone()); + + let split = if let Some(split) = split_uri(&request.message_ident) { + split + } else { + warn!( + "Dealer request with invalid message_ident: {}", + &request.message_ident + ); + return; + }; + + let handler_map = self + .request_handlers + .lock() + .expect(DEALER_REQUEST_HANDLERS_POISON_MSG); + + if let Some(handler) = handler_map.get(split) { + handler.handle_request(payload_request, responder); + return; + } + + warn!("No handler for message_ident: {}", &request.message_ident); + } + + fn dispatch(&self, m: MessageOrRequest, send_tx: &mpsc::UnboundedSender) { + match m { + MessageOrRequest::Message(m) => self.dispatch_message(m), + MessageOrRequest::Request(r) => self.dispatch_request(r, send_tx), + } + } + + async fn closed(&self) { + if self.notify_drop.acquire().await.is_ok() { + error!("should never have gotten a permit"); + } + } + + fn is_closed(&self) -> bool { + self.notify_drop.is_closed() + } +} + +struct Dealer { + shared: Arc, + handle: TimeoutOnDrop>, +} + +impl Dealer { + pub fn add_handler(&self, uri: &str, handler: H) -> Result<(), Error> + where + H: RequestHandler, + { + add_handler( + &mut self + .shared + .request_handlers + .lock() + .expect(DEALER_REQUEST_HANDLERS_POISON_MSG), + uri, + handler, + ) + } + + pub fn remove_handler(&self, uri: &str) -> Option> { + remove_handler( + &mut self + .shared + .request_handlers + .lock() + .expect(DEALER_REQUEST_HANDLERS_POISON_MSG), + uri, + ) + } + + pub fn subscribe(&self, uris: &[&str]) -> Result { + subscribe( + &mut self + .shared + .message_handlers + .lock() + .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG), + uris, + ) + } + + pub fn handles(&self, uri: &str) -> bool { + handles( + &self + .shared + .request_handlers + .lock() + .expect(DEALER_REQUEST_HANDLERS_POISON_MSG), + &self + .shared + .message_handlers + .lock() + .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG), + uri, + ) + } + + pub async fn close(mut self) { + debug!("closing dealer"); + + self.shared.notify_drop.close(); + + if let Some(handle) = self.handle.take() { + if let Err(e) = CancelOnDrop(handle).await { + error!("error aborting dealer operations: {e}"); + } + } + } +} + +/// Initializes a connection and returns futures that will finish when the connection is closed/lost. +async fn connect( + address: &Url, + proxy: Option<&Url>, + shared: &Arc, +) -> WsResult<(JoinHandle<()>, JoinHandle<()>)> { + let host = address + .host_str() + .ok_or(WsError::Url(UrlError::NoHostName))?; + + let default_port = match address.scheme() { + "ws" => 80, + "wss" => 443, + _ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme).into()), + }; + + let port = address.port().unwrap_or(default_port); + + let stream = socket::connect(host, port, proxy).await?; + + let (mut ws_tx, ws_rx) = tokio_tungstenite::client_async_tls(address.as_str(), stream) + .await? + .0 + .split(); + + let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); + + // Spawn a task that will forward messages from the channel to the websocket. + let send_task = { + let shared = Arc::clone(shared); + + tokio::spawn(async move { + let result = loop { + select! { + biased; + () = shared.closed() => { + break Ok(None); + } + msg = send_rx.recv() => { + if let Some(msg) = msg { + // New message arrived through channel + if let WsMessage::Close(close_frame) = msg { + break Ok(close_frame); + } + + if let Err(e) = ws_tx.feed(msg).await { + break Err(e); + } + } else { + break Ok(None); + } + }, + e = keep_flushing(&mut ws_tx) => { + break Err(e) + } + else => (), + } + }; + + send_rx.close(); + + // I don't trust in tokio_tungstenite's implementation of Sink::close. + let result = match result { + Ok(close_frame) => ws_tx.send(WsMessage::Close(close_frame)).await, + Err(WsError::AlreadyClosed) | Err(WsError::ConnectionClosed) => ws_tx.flush().await, + Err(e) => { + warn!("Dealer finished with an error: {e}"); + ws_tx.send(WsMessage::Close(None)).await + } + }; + + if let Err(e) = result { + warn!("Error while closing websocket: {e}"); + } + + debug!("Dropping send task"); + }) + }; + + let shared = Arc::clone(shared); + + // A task that receives messages from the web socket. + let receive_task = tokio::spawn(async { + let pong_received = AtomicBool::new(true); + let send_tx = send_tx; + let shared = shared; + + let receive_task = async { + let mut ws_rx = ws_rx; + + loop { + match ws_rx.next().await { + Some(Ok(msg)) => match msg { + WsMessage::Text(t) => match serde_json::from_str(&t) { + Ok(m) => shared.dispatch(m, &send_tx), + Err(e) => warn!("Message couldn't be parsed: {e}. Message was {t}"), + }, + WsMessage::Binary(_) => { + info!("Received invalid binary message"); + } + WsMessage::Pong(_) => { + trace!("Received pong"); + pong_received.store(true, atomic::Ordering::Relaxed); + } + _ => (), // tungstenite handles Close and Ping automatically + }, + Some(Err(e)) => { + warn!("Websocket connection failed: {e}"); + break; + } + None => { + debug!("Websocket connection closed."); + break; + } + } + } + }; + + // Sends pings and checks whether a pong comes back. + let ping_task = async { + use tokio::time::{interval, sleep}; + + let mut timer = interval(PING_INTERVAL); + + loop { + timer.tick().await; + + pong_received.store(false, atomic::Ordering::Relaxed); + if send_tx + .send(WsMessage::Ping(bytes::Bytes::default())) + .is_err() + { + // The sender is closed. + break; + } + + trace!("Sent ping"); + + sleep(PING_TIMEOUT).await; + + if !pong_received.load(atomic::Ordering::SeqCst) { + // No response + warn!("Websocket peer does not respond."); + break; + } + } + }; + + // Exit this task as soon as one our subtasks fails. + // In both cases the connection is probably lost. + select! { + () = ping_task => (), + () = receive_task => () + } + + // Try to take send_task down with us, in case it's still alive. + let _ = send_tx.send(WsMessage::Close(None)); + + debug!("Dropping receive task"); + }); + + Ok((send_task, receive_task)) +} + +/// The main background task for `Dealer`, which coordinates reconnecting. +async fn run( + shared: Arc, + initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>, + mut get_url: F, + proxy: Option, +) -> Result<(), Error> +where + Fut: Future + Send + 'static, + F: (FnMut() -> Fut) + Send + 'static, +{ + let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT)); + + let mut tasks = if let Some((s, r)) = initial_tasks { + (init_task(s), init_task(r)) + } else { + (None, None) + }; + + while !shared.is_closed() { + match &mut tasks { + (Some(t0), Some(t1)) => { + select! { + () = shared.closed() => break, + r = t0 => { + if let Err(e) = r { + error!("timeout on task 0: {e}"); + } + tasks.0.take(); + }, + r = t1 => { + if let Err(e) = r { + error!("timeout on task 1: {e}"); + } + tasks.1.take(); + } + } + } + _ => { + let url = select! { + () = shared.closed() => { + break + }, + e = get_url() => e + }?; + + match connect(&url, proxy.as_ref(), &shared).await { + Ok((s, r)) => tasks = (init_task(s), init_task(r)), + Err(e) => { + error!("Error while connecting: {e}"); + tokio::time::sleep(RECONNECT_INTERVAL).await; + } + } + } + } + } + + let tasks = tasks.0.into_iter().chain(tasks.1); + + let _ = join_all(tasks).await; + + Ok(()) +} diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs new file mode 100644 index 00000000..803fe300 --- /dev/null +++ b/core/src/dealer/protocol.rs @@ -0,0 +1,196 @@ +pub mod request; + +pub use request::*; + +use std::collections::HashMap; +use std::io::{Error as IoError, Read}; + +use crate::{Error, deserialize_with::json_proto}; +use base64::{DecodeError, Engine, prelude::BASE64_STANDARD}; +use flate2::read::GzDecoder; +use log::LevelFilter; +use serde::Deserialize; +use serde_json::Error as SerdeError; +use thiserror::Error; + +const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions { + ignore_unknown_fields: true, + _future_options: (), +}; + +type JsonValue = serde_json::Value; + +#[derive(Debug, Error)] +enum ProtocolError { + #[error("base64 decoding failed: {0}")] + Base64(DecodeError), + #[error("gzip decoding failed: {0}")] + GZip(IoError), + #[error("deserialization failed: {0}")] + Deserialization(SerdeError), + #[error("payload had more then one value. had {0} values")] + MoreThenOneValue(usize), + #[error("received unexpected data {0:#?}")] + UnexpectedData(PayloadValue), + #[error("payload was empty")] + Empty, +} + +impl From for Error { + fn from(err: ProtocolError) -> Self { + match err { + ProtocolError::UnexpectedData(_) => Error::unavailable(err), + _ => Error::failed_precondition(err), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct Payload { + pub compressed: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct WebsocketRequest { + #[serde(default)] + pub headers: HashMap, + pub message_ident: String, + pub key: String, + pub payload: Payload, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct WebsocketMessage { + #[serde(default)] + pub headers: HashMap, + pub method: Option, + #[serde(default)] + pub payloads: Vec, + pub uri: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum MessagePayloadValue { + String(String), + Bytes(Vec), + Json(JsonValue), +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum MessageOrRequest { + Message(WebsocketMessage), + Request(WebsocketRequest), +} + +#[derive(Clone, Debug)] +pub enum PayloadValue { + Empty, + Raw(Vec), + Json(String), +} + +#[derive(Clone, Debug)] +pub struct Message { + pub headers: HashMap, + pub payload: PayloadValue, + pub uri: String, +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum FallbackWrapper { + Inner(#[serde(deserialize_with = "json_proto")] T), + Fallback(JsonValue), +} + +impl Message { + pub fn try_from_json( + value: Self, + ) -> Result, Error> { + match value.payload { + PayloadValue::Json(json) => Ok(serde_json::from_str(&json)?), + other => Err(ProtocolError::UnexpectedData(other).into()), + } + } + + pub fn from_raw(value: Self) -> Result { + match value.payload { + PayloadValue::Raw(bytes) => { + M::parse_from_bytes(&bytes).map_err(Error::failed_precondition) + } + other => Err(ProtocolError::UnexpectedData(other).into()), + } + } +} + +impl WebsocketMessage { + pub fn handle_payload(&mut self) -> Result { + if self.payloads.is_empty() { + return Ok(PayloadValue::Empty); + } else if self.payloads.len() > 1 { + return Err(ProtocolError::MoreThenOneValue(self.payloads.len()).into()); + } + + let payload = self.payloads.pop().ok_or(ProtocolError::Empty)?; + let bytes = match payload { + MessagePayloadValue::String(string) => BASE64_STANDARD + .decode(string) + .map_err(ProtocolError::Base64)?, + MessagePayloadValue::Bytes(bytes) => bytes, + MessagePayloadValue::Json(json) => return Ok(PayloadValue::Json(json.to_string())), + }; + + handle_transfer_encoding(&self.headers, bytes).map(PayloadValue::Raw) + } +} + +impl WebsocketRequest { + pub fn handle_payload(&self) -> Result { + let payload_bytes = BASE64_STANDARD + .decode(&self.payload.compressed) + .map_err(ProtocolError::Base64)?; + + let payload = handle_transfer_encoding(&self.headers, payload_bytes)?; + let payload = String::from_utf8(payload)?; + + if log::max_level() >= LevelFilter::Trace { + if let Ok(json) = serde_json::from_str::(&payload) { + trace!("websocket request: {json:#?}"); + } else { + trace!("websocket request: {payload}"); + } + } + + serde_json::from_str(&payload) + .map_err(ProtocolError::Deserialization) + .map_err(Into::into) + } +} + +fn handle_transfer_encoding( + headers: &HashMap, + data: Vec, +) -> Result, Error> { + let encoding = headers.get("Transfer-Encoding").map(String::as_str); + if let Some(encoding) = encoding { + trace!("message was sent with {encoding} encoding "); + } else { + trace!("message was sent with no encoding "); + } + + if !matches!(encoding, Some("gzip")) { + return Ok(data); + } + + let mut gz = GzDecoder::new(&data[..]); + let mut bytes = vec![]; + match gz.read_to_end(&mut bytes) { + Ok(i) if i == bytes.len() => Ok(bytes), + Ok(_) => Err(Error::failed_precondition( + "read bytes mismatched with expected bytes", + )), + Err(why) => Err(ProtocolError::GZip(why).into()), + } +} diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs new file mode 100644 index 00000000..86c44cf1 --- /dev/null +++ b/core/src/dealer/protocol/request.rs @@ -0,0 +1,213 @@ +use crate::{ + deserialize_with::*, + protocol::{ + context::Context, + context_player_options::ContextPlayerOptionOverrides, + player::{PlayOrigin, ProvidedTrack}, + transfer_state::TransferState, + }, +}; +use serde::Deserialize; +use serde_json::Value; +use std::fmt::{Display, Formatter}; + +#[derive(Clone, Debug, Deserialize)] +pub struct Request { + pub message_id: u32, + // todo: did only send target_alias_id: null so far, maybe we just ignore it, will see + // pub target_alias_id: Option<()>, + pub sent_by_device_id: String, + pub command: Command, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "endpoint", rename_all = "snake_case")] +pub enum Command { + Transfer(TransferCommand), + #[serde(deserialize_with = "boxed")] + Play(Box), + Pause(PauseCommand), + SeekTo(SeekToCommand), + SetShufflingContext(SetValueCommand), + SetRepeatingTrack(SetValueCommand), + SetRepeatingContext(SetValueCommand), + AddToQueue(AddToQueueCommand), + SetQueue(SetQueueCommand), + SetOptions(SetOptionsCommand), + UpdateContext(UpdateContextCommand), + SkipNext(SkipNextCommand), + // commands that don't send any context (at least not usually...) + SkipPrev(GenericCommand), + Resume(GenericCommand), + // catch unknown commands, so that we can implement them later + #[serde(untagged)] + Unknown(Value), +} + +impl Display for Command { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + use Command::*; + + write!( + f, + "endpoint: {}{}", + matches!(self, Unknown(_)) + .then_some("unknown ") + .unwrap_or_default(), + match self { + Transfer(_) => "transfer", + Play(_) => "play", + Pause(_) => "pause", + SeekTo(_) => "seek_to", + SetShufflingContext(_) => "set_shuffling_context", + SetRepeatingContext(_) => "set_repeating_context", + SetRepeatingTrack(_) => "set_repeating_track", + AddToQueue(_) => "add_to_queue", + SetQueue(_) => "set_queue", + SetOptions(_) => "set_options", + UpdateContext(_) => "update_context", + SkipNext(_) => "skip_next", + SkipPrev(_) => "skip_prev", + Resume(_) => "resume", + Unknown(json) => { + json.as_object() + .and_then(|obj| obj.get("endpoint").map(|v| v.as_str())) + .flatten() + .unwrap_or("???") + } + } + ) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TransferCommand { + #[serde(default, deserialize_with = "base64_proto")] + pub data: Option, + pub options: TransferOptions, + pub from_device_identifier: String, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PlayCommand { + #[serde(deserialize_with = "json_proto")] + pub context: Context, + #[serde(deserialize_with = "json_proto")] + pub play_origin: PlayOrigin, + pub options: PlayOptions, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PauseCommand { + // does send options with it, but seems to be empty, investigate which options are send here + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SeekToCommand { + pub value: u32, + pub position: u32, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SkipNextCommand { + #[serde(default, deserialize_with = "option_json_proto")] + pub track: Option, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SetValueCommand { + pub value: bool, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AddToQueueCommand { + #[serde(deserialize_with = "json_proto")] + pub track: ProvidedTrack, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SetQueueCommand { + #[serde(deserialize_with = "vec_json_proto")] + pub next_tracks: Vec, + #[serde(deserialize_with = "vec_json_proto")] + pub prev_tracks: Vec, + // this queue revision is actually the last revision, so using it will not update the web ui + // might be that internally they use the last revision to create the next revision + pub queue_revision: String, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SetOptionsCommand { + pub shuffling_context: Option, + pub repeating_context: Option, + pub repeating_track: Option, + pub options: Option, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateContextCommand { + #[serde(deserialize_with = "json_proto")] + pub context: Context, + pub session_id: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GenericCommand { + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TransferOptions { + pub restore_paused: String, + pub restore_position: String, + pub restore_track: String, + pub retain_session: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PlayOptions { + pub skip_to: Option, + #[serde(default, deserialize_with = "option_json_proto")] + pub player_options_override: Option, + pub license: Option, + // possible to send wie web-api + pub seek_to: Option, + // mobile + pub always_play_something: Option, + pub audio_stream: Option, + pub initially_paused: Option, + pub prefetch_level: Option, + pub system_initiated: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct OptionsOptions { + only_for_local_device: bool, + override_restrictions: bool, + system_initiated: bool, +} + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct SkipTo { + pub track_uid: Option, + pub track_uri: Option, + pub track_index: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LoggingParams { + pub interaction_ids: Option>, + pub device_identifier: Option, + pub command_initiated_time: Option, + pub page_instance_ids: Option>, + pub command_id: Option, +} diff --git a/core/src/deserialize_with.rs b/core/src/deserialize_with.rs new file mode 100644 index 00000000..22fc24a6 --- /dev/null +++ b/core/src/deserialize_with.rs @@ -0,0 +1,90 @@ +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use protobuf::MessageFull; +use serde::de::{Error, Unexpected}; +use serde::{Deserialize, Deserializer}; +use serde_json::Value; + +const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions { + ignore_unknown_fields: true, + _future_options: (), +}; + +fn parse_value_to_msg( + value: &Value, +) -> Result { + protobuf_json_mapping::parse_from_str_with_options::(&value.to_string(), &IGNORE_UNKNOWN) +} + +pub fn base64_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + let v: String = Deserialize::deserialize(de)?; + let bytes = BASE64_STANDARD + .decode(v) + .map_err(|e| Error::custom(e.to_string()))?; + + T::parse_from_bytes(&bytes).map(Some).map_err(Error::custom) +} + +pub fn json_proto<'de, T, D>(de: D) -> Result +where + T: MessageFull, + D: Deserializer<'de>, +{ + let v: Value = Deserialize::deserialize(de)?; + parse_value_to_msg(&v).map_err(Error::custom) +} + +pub fn option_json_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + let v: Value = Deserialize::deserialize(de)?; + parse_value_to_msg(&v).map(Some).map_err(Error::custom) +} + +pub fn vec_json_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + let v: Value = Deserialize::deserialize(de)?; + let array = match v { + Value::Array(array) => array, + _ => return Err(Error::custom("the value wasn't an array")), + }; + + let res = array + .iter() + .flat_map(parse_value_to_msg) + .collect::>(); + + Ok(res) +} + +pub fn boxed<'de, T, D>(de: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + let v: T = Deserialize::deserialize(de)?; + Ok(Box::new(v)) +} + +pub fn bool_from_string<'de, D>(de: D) -> Result +where + D: Deserializer<'de>, +{ + match String::deserialize(de)?.as_ref() { + "true" => Ok(true), + "false" => Ok(false), + other => Err(Error::invalid_value( + Unexpected::Str(other), + &"true or false", + )), + } +} diff --git a/core/src/diffie_hellman.rs b/core/src/diffie_hellman.rs index 57caa029..283aa026 100644 --- a/core/src/diffie_hellman.rs +++ b/core/src/diffie_hellman.rs @@ -1,11 +1,12 @@ -use num_bigint::{BigUint, RandBigInt}; +use std::sync::LazyLock; + +use num_bigint::BigUint; use num_integer::Integer; use num_traits::{One, Zero}; -use once_cell::sync::Lazy; use rand::{CryptoRng, Rng}; -static DH_GENERATOR: Lazy = Lazy::new(|| BigUint::from_bytes_be(&[0x02])); -static DH_PRIME: Lazy = Lazy::new(|| { +static DH_GENERATOR: LazyLock = LazyLock::new(|| BigUint::from_bytes_be(&[0x02])); +static DH_PRIME: LazyLock = LazyLock::new(|| { BigUint::from_bytes_be(&[ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, 0xda, 0xa2, 0x21, 0x68, 0xc2, 0x34, 0xc4, 0xc6, 0x62, 0x8b, 0x80, 0xdc, 0x1c, 0xd1, 0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67, @@ -40,7 +41,9 @@ pub struct DhLocalKeys { impl DhLocalKeys { pub fn random(rng: &mut R) -> DhLocalKeys { - let private_key = rng.gen_biguint(95 * 8); + let mut bytes = [0u8; 95]; + rng.fill_bytes(&mut bytes); + let private_key = BigUint::from_bytes_le(&bytes); let public_key = powm(&DH_GENERATOR, &private_key, &DH_PRIME); DhLocalKeys { diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 00000000..7f299579 --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,507 @@ +use std::{ + error, fmt, + num::{ParseIntError, TryFromIntError}, + str::Utf8Error, + string::FromUtf8Error, +}; + +use base64::DecodeError; +use http::{ + header::{InvalidHeaderName, InvalidHeaderValue, ToStrError}, + method::InvalidMethod, + status::InvalidStatusCode, + uri::{InvalidUri, InvalidUriParts}, +}; +use protobuf::Error as ProtobufError; +use thiserror::Error; +use tokio::sync::{ + AcquireError, TryAcquireError, mpsc::error::SendError, oneshot::error::RecvError, +}; +use url::ParseError; + +use librespot_oauth::OAuthError; + +#[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: OAuthError) -> Self { + use OAuthError::*; + match err { + AuthCodeBadUri { .. } + | AuthCodeNotFound { .. } + | AuthCodeListenerRead + | AuthCodeListenerParse => Error::unavailable(err), + AuthCodeStdinRead + | AuthCodeListenerBind { .. } + | AuthCodeListenerTerminated + | AuthCodeListenerWrite + | Recv + | ExchangeCode { .. } => Error::internal(err), + _ => Error::failed_precondition(err), + } + } +} + +impl From for Error { + fn from(err: DecodeError) -> Self { + Self::new(ErrorKind::FailedPrecondition, 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_status() || err.is_user() { + return Self::new(ErrorKind::Internal, err); + } + + if err.is_canceled() { + return Self::new(ErrorKind::Cancelled, 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: hyper_util::client::legacy::Error) -> Self { + if err.is_connect() { + return Self::new(ErrorKind::Unavailable, err); + } + + Self::new(ErrorKind::Unknown, err) + } +} + +impl From for Error { + fn from(err: time::error::Parse) -> Self { + Self::new(ErrorKind::FailedPrecondition, 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: TryFromIntError) -> 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: AcquireError) -> Self { + Self { + kind: ErrorKind::ResourceExhausted, + error: ErrorMessage(err.to_string()).into(), + } + } +} + +impl From for Error { + fn from(err: TryAcquireError) -> Self { + Self { + kind: ErrorKind::ResourceExhausted, + 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) + } +} + +impl From for Error { + fn from(err: protobuf_json_mapping::ParseError) -> Self { + Self::failed_precondition(err) + } +} diff --git a/core/src/file_id.rs b/core/src/file_id.rs new file mode 100644 index 00000000..e513d97d --- /dev/null +++ b/core/src/file_id.rs @@ -0,0 +1,62 @@ +use std::fmt; + +use librespot_protocol as protocol; + +use crate::{Error, spotify_id::to_base16}; + +const RAW_LEN: usize = 20; + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FileId(pub [u8; RAW_LEN]); + +impl FileId { + pub fn from_raw(src: &[u8]) -> FileId { + let mut dst = [0u8; RAW_LEN]; + let len = src.len(); + // some tracks return 16 instead of 20 bytes: #1188 + if len <= RAW_LEN { + dst[..len].clone_from_slice(src); + } + FileId(dst) + } + + #[allow(clippy::wrong_self_convention)] + pub fn to_base16(&self) -> Result { + to_base16(&self.0, &mut [0u8; 40]) + } +} + +impl fmt::Debug for FileId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("FileId").field(&self.to_base16()).finish() + } +} + +impl fmt::Display for FileId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_base16().unwrap_or_default()) + } +} + +impl From<&[u8]> for FileId { + fn from(src: &[u8]) -> Self { + Self::from_raw(src) + } +} +impl From<&protocol::metadata::Image> for FileId { + fn from(image: &protocol::metadata::Image) -> Self { + Self::from(image.file_id()) + } +} + +impl From<&protocol::metadata::AudioFile> for FileId { + fn from(file: &protocol::metadata::AudioFile) -> Self { + Self::from(file.file_id()) + } +} + +impl From<&protocol::metadata::VideoFile> for FileId { + fn from(video: &protocol::metadata::VideoFile) -> Self { + Self::from(video.file_id()) + } +} diff --git a/core/src/http_client.rs b/core/src/http_client.rs new file mode 100644 index 00000000..bbd63838 --- /dev/null +++ b/core/src/http_client.rs @@ -0,0 +1,304 @@ +use std::{ + sync::OnceLock, + time::{Duration, Instant}, +}; + +use bytes::Bytes; +use futures_util::{FutureExt, future::IntoStream}; +use governor::{ + Quota, RateLimiter, clock::MonotonicClock, middleware::NoOpMiddleware, + state::keyed::DefaultKeyedStateStore, +}; +use http::{Uri, header::HeaderValue}; +use http_body_util::{BodyExt, Full}; +use hyper::{HeaderMap, Request, Response, StatusCode, body::Incoming, header::USER_AGENT}; +use hyper_proxy2::{Intercept, Proxy, ProxyConnector}; +use hyper_util::{ + client::legacy::{Client, ResponseFuture, connect::HttpConnector}, + rt::TokioExecutor, +}; +use nonzero_ext::nonzero; +use thiserror::Error; +use url::Url; + +#[cfg(all(feature = "__rustls", not(feature = "native-tls")))] +use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; +#[cfg(all(feature = "native-tls", not(feature = "__rustls")))] +use hyper_tls::HttpsConnector; + +use crate::{ + Error, + config::{OS, os_version}, + date::Date, + version::{FALLBACK_USER_AGENT, VERSION_STRING, spotify_version}, +}; + +// The 30 seconds interval is documented by Spotify, but the calls per interval +// is a guesstimate and probably subject to licensing (purchasing extra calls) +// and may change at any time. +pub const RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(30); +pub const RATE_LIMIT_MAX_WAIT: Duration = Duration::from_secs(10); +pub const RATE_LIMIT_CALLS_PER_INTERVAL: u32 = 300; + +#[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), + } + } + } + } +} + +type HyperClient = Client>, Full>; + +pub struct HttpClient { + user_agent: HeaderValue, + proxy_url: Option, + hyper_client: OnceLock, + + rate_limiter: + RateLimiter, MonotonicClock, NoOpMiddleware>, +} + +impl HttpClient { + pub fn new(proxy_url: Option<&Url>) -> Self { + let zero_str = String::from("0"); + let os_version = os_version(); + + let (spotify_platform, os_version) = match OS { + "android" => ("Android", os_version), + "ios" => ("iOS", os_version), + "macos" => ("OSX", zero_str), + "windows" => ("Win32", zero_str), + _ => ("Linux", zero_str), + }; + + let user_agent_str = &format!( + "Spotify/{} {}/{} ({})", + spotify_version(), + spotify_platform, + os_version, + VERSION_STRING + ); + + let user_agent = HeaderValue::from_str(user_agent_str).unwrap_or_else(|err| { + error!("Invalid user agent <{user_agent_str}>: {err}"); + HeaderValue::from_static(FALLBACK_USER_AGENT) + }); + + let replenish_interval_ns = + RATE_LIMIT_INTERVAL.as_nanos() / RATE_LIMIT_CALLS_PER_INTERVAL as u128; + let quota = Quota::with_period(Duration::from_nanos(replenish_interval_ns as u64)) + .expect("replenish interval should be valid") + .allow_burst(nonzero![RATE_LIMIT_CALLS_PER_INTERVAL]); + let rate_limiter = RateLimiter::keyed(quota); + + Self { + user_agent, + proxy_url: proxy_url.cloned(), + hyper_client: OnceLock::new(), + rate_limiter, + } + } + + fn try_create_hyper_client(proxy_url: Option<&Url>) -> Result { + // configuring TLS is expensive and should be done once per process + + #[cfg(all(feature = "__rustls", not(feature = "native-tls")))] + let https_connector = { + #[cfg(feature = "rustls-tls-native-roots")] + let tls = HttpsConnectorBuilder::new().with_native_roots()?; + #[cfg(feature = "rustls-tls-webpki-roots")] + let tls = HttpsConnectorBuilder::new().with_webpki_roots(); + tls.https_or_http().enable_http1().enable_http2().build() + }; + + #[cfg(all(feature = "native-tls", not(feature = "__rustls")))] + let https_connector = HttpsConnector::new(); + + // When not using a proxy a dummy proxy is configured that will not intercept any traffic. + // This prevents needing to carry the Client Connector generics through the whole project + let proxy = match &proxy_url { + Some(proxy_url) => Proxy::new(Intercept::All, proxy_url.to_string().parse()?), + None => Proxy::new(Intercept::None, Uri::from_static("0.0.0.0")), + }; + let proxy_connector = ProxyConnector::from_proxy(https_connector, proxy)?; + + let client = Client::builder(TokioExecutor::new()) + .http2_adaptive_window(true) + .build(proxy_connector); + Ok(client) + } + + fn hyper_client(&self) -> &HyperClient { + self.hyper_client + .get_or_init(|| Self::try_create_hyper_client(self.proxy_url.as_ref()).unwrap()) + } + + pub async fn request(&self, req: Request) -> Result, Error> { + debug!("Requesting {}", req.uri()); + + // `Request` does not implement `Clone` because its `Body` may be a single-shot stream. + // As correct as that may be technically, we now need all this boilerplate to clone it + // ourselves, as any `Request` is moved in the loop. + let (parts, body_as_bytes) = req.into_parts(); + + loop { + let mut req = Request::builder() + .method(parts.method.clone()) + .uri(parts.uri.clone()) + .version(parts.version) + .body(body_as_bytes.clone())?; + *req.headers_mut() = parts.headers.clone(); + + let request = self.request_fut(req)?; + let response = request.await; + + if let Ok(response) = &response { + let code = response.status(); + + if code == StatusCode::TOO_MANY_REQUESTS { + if let Some(duration) = Self::get_retry_after(response.headers()) { + warn!( + "Rate limited by service, retrying in {} seconds...", + duration.as_secs() + ); + tokio::time::sleep(duration).await; + continue; + } + } + + if !code.is_success() { + return Err(HttpClientError::StatusCode(code).into()); + } + } + + let response = response?; + return Ok(response); + } + } + + pub async fn request_body(&self, req: Request) -> Result { + let response = self.request(req).await?; + Ok(response.into_body().collect().await?.to_bytes()) + } + + pub fn request_stream(&self, req: Request) -> Result, Error> { + Ok(self.request_fut(req)?.into_stream()) + } + + pub fn request_fut(&self, mut req: Request) -> Result { + let headers_mut = req.headers_mut(); + headers_mut.insert(USER_AGENT, self.user_agent.clone()); + + // For rate limiting we cannot *just* depend on Spotify sending us HTTP/429 + // Retry-After headers. For example, when there is a service interruption + // and HTTP/500 is returned, we don't want to DoS the Spotify infrastructure. + let domain = match req.uri().host() { + Some(host) => { + // strip the prefix from *.domain.tld (assume rate limit is per domain, not subdomain) + let mut parts = host + .split('.') + .map(|s| s.to_string()) + .collect::>(); + let n = parts.len().saturating_sub(2); + parts.drain(n..).collect() + } + None => String::from(""), + }; + self.rate_limiter.check_key(&domain).map_err(|e| { + Error::resource_exhausted(format!( + "rate limited for at least another {} seconds", + e.wait_time_from(Instant::now()).as_secs() + )) + })?; + + Ok(self.hyper_client().request(req.map(Full::new))) + } + + pub fn get_retry_after(headers: &HeaderMap) -> Option { + let now = Date::now_utc().as_timestamp_ms(); + + let mut retry_after_ms = None; + if let Some(header_val) = headers.get("X-RateLimit-Next") { + // *.akamaized.net (Akamai) + if let Ok(date_str) = header_val.to_str() { + if let Ok(target) = Date::from_iso8601(date_str) { + retry_after_ms = Some(target.as_timestamp_ms().saturating_sub(now)) + } + } + } else if let Some(header_val) = headers.get("Fastly-RateLimit-Reset") { + // *.scdn.co (Fastly) + if let Ok(timestamp) = header_val.to_str() { + if let Ok(target) = timestamp.parse::() { + retry_after_ms = Some(target.saturating_sub(now)) + } + } + } else if let Some(header_val) = headers.get("Retry-After") { + // Generic RFC compliant (including *.spotify.com) + if let Ok(retry_after) = header_val.to_str() { + if let Ok(duration) = retry_after.parse::() { + retry_after_ms = Some(duration * 1000) + } + } + } + + if let Some(retry_after) = retry_after_ms { + let duration = Duration::from_millis(retry_after as u64); + if duration <= RATE_LIMIT_MAX_WAIT { + return Some(duration); + } else { + debug!( + "Waiting {} seconds would exceed {} second limit", + duration.as_secs(), + RATE_LIMIT_MAX_WAIT.as_secs() + ); + } + } + + None + } +} diff --git a/core/src/keymaster.rs b/core/src/keymaster.rs deleted file mode 100644 index 8c3c00a2..00000000 --- a/core/src/keymaster.rs +++ /dev/null @@ -1,26 +0,0 @@ -use serde::Deserialize; - -use crate::{mercury::MercuryError, session::Session}; - -#[derive(Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Token { - pub access_token: String, - pub expires_in: u32, - pub token_type: String, - pub scope: Vec, -} - -pub async fn get_token( - session: &Session, - client_id: &str, - scopes: &str, -) -> Result { - let url = format!( - "hm://keymaster/token/authenticated?client_id={}&scope={}", - client_id, scopes - ); - let response = session.mercury().get(url).await?; - let data = response.payload.first().expect("Empty payload"); - serde_json::from_slice(data.as_ref()).map_err(|_| MercuryError) -} diff --git a/core/src/lib.rs b/core/src/lib.rs index 9afb99a3..f4ead234 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unused_io_amount)] - #[macro_use] extern crate log; @@ -8,20 +6,41 @@ 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)] +pub mod dealer; +pub mod deserialize_with; #[doc(hidden)] pub mod diffie_hellman; -pub mod keymaster; +pub mod error; +pub mod file_id; +pub mod http_client; +pub mod login5; pub mod mercury; +pub mod packet; mod proxytunnel; pub mod session; +mod socket; +#[allow(dead_code)] +pub mod spclient; pub mod spotify_id; +pub mod spotify_uri; +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; +pub use spotify_uri::SpotifyUri; diff --git a/core/src/login5.rs b/core/src/login5.rs new file mode 100644 index 00000000..6a2f9bf4 --- /dev/null +++ b/core/src/login5.rs @@ -0,0 +1,270 @@ +use crate::config::OS; +use crate::spclient::CLIENT_TOKEN; +use crate::token::Token; +use crate::{Error, SessionConfig, util}; +use bytes::Bytes; +use http::{HeaderValue, Method, Request, header::ACCEPT}; +use librespot_protocol::login5::login_response::Response; +use librespot_protocol::{ + client_info::ClientInfo, + credentials::{Password, StoredCredential}, + hashcash::HashcashSolution, + login5::{ + ChallengeSolution, LoginError, LoginOk, LoginRequest, LoginResponse, + login_request::Login_method, + }, +}; +use protobuf::well_known_types::duration::Duration as ProtoDuration; +use protobuf::{Message, MessageField}; +use std::time::{Duration, Instant}; +use thiserror::Error; +use tokio::time::sleep; + +const MAX_LOGIN_TRIES: u8 = 3; +const LOGIN_TIMEOUT: Duration = Duration::from_secs(3); + +component! { + Login5Manager : Login5ManagerInner { + auth_token: Option = None, + } +} + +#[derive(Debug, Error)] +enum Login5Error { + #[error("Login request was denied: {0:?}")] + FaultyRequest(LoginError), + #[error("Code challenge is not supported")] + CodeChallenge, + #[error("Tried to acquire token without stored credentials")] + NoStoredCredentials, + #[error("Couldn't successfully authenticate after {0} times")] + RetriesFailed(u8), + #[error("Login via login5 is only allowed for android or ios")] + OnlyForMobile, +} + +impl From for Error { + fn from(err: Login5Error) -> Self { + match err { + Login5Error::NoStoredCredentials | Login5Error::OnlyForMobile => { + Error::unavailable(err) + } + Login5Error::RetriesFailed(_) | Login5Error::FaultyRequest(_) => { + Error::failed_precondition(err) + } + Login5Error::CodeChallenge => Error::unimplemented(err), + } + } +} + +impl Login5Manager { + async fn request(&self, message: &LoginRequest) -> Result { + let client_token = self.session().spclient().client_token().await?; + let body = message.write_to_bytes()?; + + let request = Request::builder() + .method(&Method::POST) + .uri("https://login5.spotify.com/v3/login") + .header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) + .header(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?) + .body(body.into())?; + + self.session().http_client().request_body(request).await + } + + async fn login5_request(&self, login: Login_method) -> Result { + let client_id = match OS { + "macos" | "windows" => self.session().client_id(), + // StoredCredential is used to get an access_token from Session credentials. + // Using the session client_id allows user to use Keymaster on Android/IOS + // if their Credentials::with_access_token was obtained there, assuming + // they have overriden the SessionConfig::client_id with the Keymaster's. + _ if matches!(login, Login_method::StoredCredential(_)) => self.session().client_id(), + _ => SessionConfig::default().client_id, + }; + + let mut login_request = LoginRequest { + client_info: MessageField::some(ClientInfo { + client_id, + device_id: self.session().device_id().to_string(), + special_fields: Default::default(), + }), + login_method: Some(login), + ..Default::default() + }; + + let mut response = self.request(&login_request).await?; + let mut count = 0; + + loop { + count += 1; + + let message = LoginResponse::parse_from_bytes(&response)?; + if let Some(Response::Ok(ok)) = message.response { + break Ok(ok); + } + + if message.has_error() { + match message.error() { + LoginError::TIMEOUT | LoginError::TOO_MANY_ATTEMPTS => { + sleep(LOGIN_TIMEOUT).await + } + others => return Err(Login5Error::FaultyRequest(others).into()), + } + } + + if message.has_challenges() { + // handles the challenges, and updates the login context with the response + Self::handle_challenges(&mut login_request, message)?; + } + + if count < MAX_LOGIN_TRIES { + response = self.request(&login_request).await?; + } else { + return Err(Login5Error::RetriesFailed(MAX_LOGIN_TRIES).into()); + } + } + } + + /// Login for android and ios + /// + /// This request doesn't require a connected session as it is the entrypoint for android or ios + /// + /// This request will only work when: + /// - client_id => android or ios | can be easily adjusted in [SessionConfig::default_for_os] + /// - user-agent => android or ios | has to be adjusted in [HttpClient::new](crate::http_client::HttpClient::new) + pub async fn login( + &self, + id: impl Into, + password: impl Into, + ) -> Result<(Token, Vec), Error> { + if !matches!(OS, "android" | "ios") { + // by manipulating the user-agent and client-id it can be also used/tested on desktop + return Err(Login5Error::OnlyForMobile.into()); + } + + let method = Login_method::Password(Password { + id: id.into(), + password: password.into(), + ..Default::default() + }); + + let token_response = self.login5_request(method).await?; + let auth_token = Self::token_from_login( + token_response.access_token, + token_response.access_token_expires_in, + ); + + Ok((auth_token, token_response.stored_credential)) + } + + /// Retrieve the access_token via login5 + /// + /// This request will only work when the store credentials match the client-id. Meaning that + /// stored credentials generated with the keymaster client-id will not work, for example, with + /// the android client-id. + pub async fn auth_token(&self) -> Result { + let auth_data = self.session().auth_data(); + if auth_data.is_empty() { + return Err(Login5Error::NoStoredCredentials.into()); + } + + let auth_token = self.lock(|inner| { + if let Some(token) = &inner.auth_token { + if token.is_expired() { + inner.auth_token = None; + } + } + inner.auth_token.clone() + }); + + if let Some(auth_token) = auth_token { + return Ok(auth_token); + } + + let method = Login_method::StoredCredential(StoredCredential { + username: self.session().username().to_string(), + data: auth_data, + ..Default::default() + }); + + let token_response = self.login5_request(method).await?; + let auth_token = Self::token_from_login( + token_response.access_token, + token_response.access_token_expires_in, + ); + + let token = self.lock(|inner| { + inner.auth_token = Some(auth_token.clone()); + inner.auth_token.clone() + }); + + trace!("Got auth token: {auth_token:?}"); + + token.ok_or(Login5Error::NoStoredCredentials.into()) + } + + fn handle_challenges( + login_request: &mut LoginRequest, + message: LoginResponse, + ) -> Result<(), Error> { + let challenges = message.challenges(); + debug!( + "Received {} challenges, solving...", + challenges.challenges.len() + ); + + for challenge in &challenges.challenges { + if challenge.has_code() { + return Err(Login5Error::CodeChallenge.into()); + } else if !challenge.has_hashcash() { + debug!("Challenge was empty, skipping..."); + continue; + } + + let hash_cash_challenge = challenge.hashcash(); + + let mut suffix = [0u8; 0x10]; + let duration = util::solve_hash_cash( + &message.login_context, + &hash_cash_challenge.prefix, + hash_cash_challenge.length, + &mut suffix, + )?; + + let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32); + debug!("Solving hashcash took {seconds}s {nanos}ns"); + + let mut solution = ChallengeSolution::new(); + solution.set_hashcash(HashcashSolution { + suffix: Vec::from(suffix), + duration: MessageField::some(ProtoDuration { + seconds, + nanos, + ..Default::default() + }), + ..Default::default() + }); + + login_request + .challenge_solutions + .mut_or_insert_default() + .solutions + .push(solution); + } + + login_request.login_context = message.login_context; + + Ok(()) + } + + fn token_from_login(token: String, expires_in: i32) -> Token { + Token { + access_token: token, + expires_in: Duration::from_secs(expires_in.try_into().unwrap_or(3600)), + token_type: "Bearer".to_string(), + scopes: vec![], + timestamp: Instant::now(), + } + } +} diff --git a/core/src/mercury/mod.rs b/core/src/mercury/mod.rs index 57650087..b32aeb11 100644 --- a/core/src/mercury/mod.rs +++ b/core/src/mercury/mod.rs @@ -1,9 +1,9 @@ -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, + pin::Pin, + task::{Context, Poll}, +}; use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; @@ -11,8 +11,7 @@ use futures_util::FutureExt; use protobuf::Message; use tokio::sync::{mpsc, oneshot}; -use crate::protocol; -use crate::util::SeqGenerator; +use crate::{Error, packet::PacketType, protocol, util::SeqGenerator}; mod types; pub use self::types::*; @@ -32,18 +31,18 @@ component! { pub struct MercuryPending { parts: Vec>, partial: Option>, - callback: Option>>, + callback: Option>>, } pub struct MercuryFuture { - receiver: oneshot::Receiver>, + receiver: oneshot::Receiver>, } impl Future for MercuryFuture { - type Output = Result; + type Output = Result; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - self.receiver.poll_unpin(cx).map_err(|_| MercuryError)? + self.receiver.poll_unpin(cx)? } } @@ -54,7 +53,7 @@ impl MercuryManager { seq } - fn request(&self, req: MercuryRequest) -> MercuryFuture { + fn request(&self, req: MercuryRequest) -> Result, Error> { let (tx, rx) = oneshot::channel(); let pending = MercuryPending { @@ -71,13 +70,13 @@ impl MercuryManager { }); let cmd = req.method.command(); - let data = req.encode(&seq); + let data = req.encode(&seq)?; - self.session().send_packet(cmd, data); - MercuryFuture { receiver: rx } + self.session().send_packet(cmd, data)?; + Ok(MercuryFuture { receiver: rx }) } - pub fn get>(&self, uri: T) -> MercuryFuture { + pub fn get>(&self, uri: T) -> Result, Error> { self.request(MercuryRequest { method: MercuryMethod::Get, uri: uri.into(), @@ -86,7 +85,11 @@ impl MercuryManager { }) } - pub fn send>(&self, uri: T, data: Vec) -> MercuryFuture { + pub fn send>( + &self, + uri: T, + data: Vec, + ) -> Result, Error> { self.request(MercuryRequest { method: MercuryMethod::Send, uri: uri.into(), @@ -102,7 +105,7 @@ impl MercuryManager { pub fn subscribe>( &self, uri: T, - ) -> impl Future, MercuryError>> + 'static + ) -> impl Future, Error>> + 'static { let uri = uri.into(); let request = self.request(MercuryRequest { @@ -114,7 +117,7 @@ impl MercuryManager { let manager = self.clone(); async move { - let response = request.await?; + let response = request?.await?; let (tx, rx) = mpsc::unbounded_channel(); @@ -124,13 +127,18 @@ impl MercuryManager { if !response.payload.is_empty() { // Old subscription protocol, watch the provided list of URIs for sub in response.payload { - let mut sub = - protocol::pubsub::Subscription::parse_from_bytes(&sub).unwrap(); - let sub_uri = sub.take_uri(); + match protocol::pubsub::Subscription::parse_from_bytes(&sub) { + Ok(mut sub) => { + let sub_uri = sub.take_uri(); - debug!("subscribed sub_uri={}", sub_uri); + debug!("subscribed sub_uri={sub_uri}"); - inner.subscriptions.push((sub_uri, tx.clone())); + inner.subscriptions.push((sub_uri, tx.clone())); + } + Err(e) => { + error!("could not subscribe to {uri}: {e}"); + } + } } } else { // New subscription protocol, watch the requested URI @@ -143,7 +151,28 @@ impl MercuryManager { } } - pub(crate) fn dispatch(&self, cmd: u8, mut data: Bytes) { + pub fn listen_for>( + &self, + uri: T, + ) -> impl Future> + 'static { + let uri = uri.into(); + + let manager = self.clone(); + async move { + let (tx, rx) = mpsc::unbounded_channel(); + + manager.lock(move |inner| { + if !inner.invalid { + debug!("listening to uri={uri}"); + inner.subscriptions.push((uri, tx)); + } + }); + + rx + } + } + + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { let seq_len = BigEndian::read_u16(data.split_to(2).as_ref()) as usize; let seq = data.split_to(seq_len).as_ref().to_owned(); @@ -154,20 +183,23 @@ impl MercuryManager { let mut pending = match pending { Some(pending) => pending, - None if cmd == 0xb5 => MercuryPending { - parts: Vec::new(), - partial: None, - callback: None, - }, None => { - warn!("Ignore seq {:?} cmd {:x}", seq, cmd); - return; + if let PacketType::MercuryEvent = cmd { + MercuryPending { + parts: Vec::new(), + partial: None, + callback: None, + } + } else { + warn!("Ignore seq {:?} cmd {:x}", seq, cmd as u8); + return Err(MercuryError::Command(cmd).into()); + } } }; for i in 0..count { let mut part = Self::parse_part(&mut data); - if let Some(mut partial) = mem::replace(&mut pending.partial, None) { + if let Some(mut partial) = pending.partial.take() { partial.extend_from_slice(&part); part = partial; } @@ -180,10 +212,12 @@ impl MercuryManager { } if flags == 0x1 { - self.complete_request(cmd, pending); + self.complete_request(cmd, pending)?; } else { self.lock(move |inner| inner.pending.insert(seq, pending)); } + + Ok(()) } fn parse_part(data: &mut Bytes) -> Vec { @@ -191,40 +225,44 @@ impl MercuryManager { data.split_to(size).as_ref().to_owned() } - fn complete_request(&self, cmd: u8, mut pending: MercuryPending) { + fn complete_request(&self, cmd: PacketType, mut pending: MercuryPending) -> Result<(), Error> { let header_data = pending.parts.remove(0); - let header = protocol::mercury::Header::parse_from_bytes(&header_data).unwrap(); + let header = protocol::mercury::Header::parse_from_bytes(&header_data)?; let response = MercuryResponse { - uri: header.get_uri().to_string(), - status_code: header.get_status_code(), + uri: header.uri().to_string(), + status_code: header.status_code(), payload: pending.parts, }; - if response.status_code >= 500 { - panic!("Spotify servers returned an error. Restart librespot."); - } else if response.status_code >= 400 { - warn!("error {} for uri {}", response.status_code, &response.uri); + let status_code = response.status_code; + if status_code >= 500 { + error!("error {} for uri {}", status_code, &response.uri); + Err(MercuryError::Response(response).into()) + } else if status_code >= 400 { + error!("error {} for uri {}", status_code, &response.uri); if let Some(cb) = pending.callback { - let _ = cb.send(Err(MercuryError)); + cb.send(Err(MercuryError::Response(response.clone()).into())) + .map_err(|_| MercuryError::Channel)?; } - } else if cmd == 0xb5 { + Err(MercuryError::Response(response).into()) + } else if let PacketType::MercuryEvent = cmd { + // TODO: This is just a workaround to make utf-8 encoded usernames work. + // A better solution would be to use an uri struct and urlencode it directly + // before sending while saving the subscription under its unencoded form. + let mut uri_split = response.uri.split('/'); + + let encoded_uri = std::iter::once(uri_split.next().unwrap_or_default().to_string()) + .chain(uri_split.map(|component| { + form_urlencoded::byte_serialize(component.as_bytes()).collect::() + })) + .collect::>() + .join("/"); + + let mut found = false; + self.lock(|inner| { - let mut found = false; - - // TODO: This is just a workaround to make utf-8 encoded usernames work. - // A better solution would be to use an uri struct and urlencode it directly - // before sending while saving the subscription under its unencoded form. - let mut uri_split = response.uri.split('/'); - - let encoded_uri = std::iter::once(uri_split.next().unwrap().to_string()) - .chain(uri_split.map(|component| { - form_urlencoded::byte_serialize(component.as_bytes()).collect::() - })) - .collect::>() - .join("/"); - - inner.subscriptions.retain(|&(ref prefix, ref sub)| { + inner.subscriptions.retain(|(prefix, sub)| { if encoded_uri.starts_with(prefix) { found = true; @@ -236,13 +274,24 @@ impl MercuryManager { true } }); + }); - if !found { - debug!("unknown subscription uri={}", response.uri); - } - }) + if found { + Ok(()) + } else if self.session().dealer().handles(&response.uri) { + trace!("mercury response <{}> is handled by dealer", response.uri); + Ok(()) + } else { + debug!("unknown subscription uri={}", &response.uri); + trace!("response pushed over Mercury: {response:?}"); + Err(MercuryError::Response(response).into()) + } } else if let Some(cb) = pending.callback { - let _ = cb.send(Ok(response)); + cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?; + Ok(()) + } else { + error!("can't handle Mercury response: {response:?}"); + Err(MercuryError::Response(response).into()) } } diff --git a/core/src/mercury/sender.rs b/core/src/mercury/sender.rs index 268554d9..31409e88 100644 --- a/core/src/mercury/sender.rs +++ b/core/src/mercury/sender.rs @@ -1,6 +1,8 @@ use std::collections::VecDeque; -use super::*; +use super::{MercuryFuture, MercuryManager, MercuryResponse}; + +use crate::Error; pub struct MercurySender { mercury: MercuryManager, @@ -23,12 +25,13 @@ impl MercurySender { self.buffered_future.is_none() && self.pending.is_empty() } - pub fn send(&mut self, item: Vec) { - let task = self.mercury.send(self.uri.clone(), item); + pub fn send(&mut self, item: Vec) -> Result<(), Error> { + let task = self.mercury.send(self.uri.clone(), item)?; self.pending.push_back(task); + Ok(()) } - pub async fn flush(&mut self) -> Result<(), MercuryError> { + pub async fn flush(&mut self) -> Result<(), Error> { if self.buffered_future.is_none() { self.buffered_future = self.pending.pop_front(); } diff --git a/core/src/mercury/types.rs b/core/src/mercury/types.rs index 402a954c..3cc8d8bc 100644 --- a/core/src/mercury/types.rs +++ b/core/src/mercury/types.rs @@ -1,8 +1,10 @@ -use byteorder::{BigEndian, WriteBytesExt}; -use protobuf::Message; use std::io::Write; -use crate::protocol; +use byteorder::{BigEndian, WriteBytesExt}; +use protobuf::Message; +use thiserror::Error; + +use crate::{Error, packet::PacketType, protocol}; #[derive(Debug, PartialEq, Eq)] pub enum MercuryMethod { @@ -27,40 +29,56 @@ 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 ToString for MercuryMethod { - fn to_string(&self) -> String { - match *self { +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 std::fmt::Display for MercuryMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match *self { MercuryMethod::Get => "GET", MercuryMethod::Sub => "SUB", MercuryMethod::Unsub => "UNSUB", MercuryMethod::Send => "SEND", - } - .to_owned() + }; + write!(f, "{s}") } } impl MercuryMethod { - pub fn command(&self) -> u8 { + pub fn command(&self) -> PacketType { + use PacketType::*; match *self { - MercuryMethod::Get | MercuryMethod::Send => 0xb2, - MercuryMethod::Sub => 0xb3, - MercuryMethod::Unsub => 0xb4, + MercuryMethod::Get | MercuryMethod::Send => MercuryReq, + MercuryMethod::Sub => MercurySub, + MercuryMethod::Unsub => MercuryUnsub, } } } impl MercuryRequest { - pub fn encode(&self, seq: &[u8]) -> Vec { + pub fn encode(&self, seq: &[u8]) -> Result, Error> { let mut packet = Vec::new(); - packet.write_u16::(seq.len() as u16).unwrap(); - packet.write_all(seq).unwrap(); - packet.write_u8(1).unwrap(); // Flags: FINAL - packet - .write_u16::(1 + self.payload.len() as u16) - .unwrap(); // Part count + packet.write_u16::(seq.len() as u16)?; + packet.write_all(seq)?; + packet.write_u8(1)?; // Flags: FINAL + packet.write_u16::(1 + self.payload.len() as u16)?; // Part count let mut header = protocol::mercury::Header::new(); header.set_uri(self.uri.clone()); @@ -70,16 +88,14 @@ impl MercuryRequest { header.set_content_type(content_type.clone()); } - packet - .write_u16::(header.compute_size() as u16) - .unwrap(); - header.write_to_writer(&mut packet).unwrap(); + packet.write_u16::(header.compute_size() as u16)?; + header.write_to_writer(&mut packet)?; for p in &self.payload { - packet.write_u16::(p.len() as u16).unwrap(); - packet.write(p).unwrap(); + packet.write_u16::(p.len() as u16)?; + packet.write_all(p)?; } - packet + Ok(packet) } } diff --git a/core/src/packet.rs b/core/src/packet.rs new file mode 100644 index 00000000..2f50d158 --- /dev/null +++ b/core/src/packet.rs @@ -0,0 +1,41 @@ +// Ported from librespot-java. Relicensed under MIT with permission. + +use num_derive::{FromPrimitive, ToPrimitive}; + +#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)] +pub enum PacketType { + SecretBlock = 0x02, + Ping = 0x04, + StreamChunk = 0x08, + StreamChunkRes = 0x09, + ChannelError = 0x0a, + ChannelAbort = 0x0b, + RequestKey = 0x0c, + AesKey = 0x0d, + AesKeyError = 0x0e, + Image = 0x19, + CountryCode = 0x1b, + Pong = 0x49, + PongAck = 0x4a, + Pause = 0x4b, + ProductInfo = 0x50, + LegacyWelcome = 0x69, + LicenseVersion = 0x76, + Login = 0xab, + APWelcome = 0xac, + AuthFailure = 0xad, + MercuryReq = 0xb2, + MercurySub = 0xb3, + MercuryUnsub = 0xb4, + MercuryEvent = 0xb5, + TrackEndedTime = 0x82, + UnknownDataAllZeros = 0x1f, + PreferredLocale = 0x74, + Unknown0x0f = 0x0f, + Unknown0x10 = 0x10, + Unknown0x4f = 0x4f, + + // TODO - occurs when subscribing with an empty URI. Maybe a MercuryError? + // Payload: b"\0\x08\0\0\0\0\0\0\0\0\x01\0\x01\0\x03 \xb0\x06" + Unknown0xb6 = 0xb6, +} diff --git a/core/src/proxytunnel.rs b/core/src/proxytunnel.rs index 6f1587f0..9e8cd3b7 100644 --- a/core/src/proxytunnel.rs +++ b/core/src/proxytunnel.rs @@ -22,7 +22,7 @@ pub async fn proxy_connect( loop { let bytes_read = proxy_connection.read(&mut buffer[offset..]).await?; if bytes_read == 0 { - return Err(io::Error::new(io::ErrorKind::Other, "Early EOF from proxy")); + return Err(io::Error::other("Early EOF from proxy")); } offset += bytes_read; @@ -31,20 +31,17 @@ pub async fn proxy_connect( let status = response .parse(&buffer[..offset]) - .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; + .map_err(io::Error::other)?; if status.is_complete() { return match response.code { Some(200) => Ok(proxy_connection), // Proxy says all is well Some(code) => { let reason = response.reason.unwrap_or("no reason"); - let msg = format!("Proxy responded with {}: {}", code, reason); - Err(io::Error::new(io::ErrorKind::Other, msg)) + let msg = format!("Proxy responded with {code}: {reason}"); + Err(io::Error::other(msg)) } - None => Err(io::Error::new( - io::ErrorKind::Other, - "Malformed response from proxy", - )), + None => Err(io::Error::other("Malformed response from proxy")), }; } diff --git a/core/src/session.rs b/core/src/session.rs index 6c4abc54..333678fd 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -1,29 +1,49 @@ -use std::future::Future; -use std::io; -use std::pin::Pin; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, RwLock, Weak}; -use std::task::Context; -use std::task::Poll; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + collections::HashMap, + future::Future, + io, + pin::Pin, + process::exit, + sync::{Arc, OnceLock, RwLock, Weak}, + task::{Context, Poll}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use crate::dealer::manager::DealerManager; +use crate::{ + Error, + apresolve::{ApResolver, SocketAddress}, + audio_key::AudioKeyManager, + authentication::Credentials, + cache::Cache, + channel::ChannelManager, + config::SessionConfig, + connection::{self, AuthenticationError, Transport}, + http_client::HttpClient, + login5::Login5Manager, + mercury::MercuryManager, + packet::PacketType, + protocol::keyexchange::ErrorCode, + spclient::SpClient, + token::TokenProvider, +}; use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; use futures_core::TryStream; -use futures_util::{future, ready, StreamExt, TryStreamExt}; -use once_cell::sync::OnceCell; +use futures_util::StreamExt; +use librespot_protocol::authentication::AuthenticationType; +use num_traits::FromPrimitive; +use pin_project_lite::pin_project; +use quick_xml::events::Event; use thiserror::Error; -use tokio::sync::mpsc; +use tokio::{ + sync::mpsc, + time::{Duration as TokioDuration, Instant as TokioInstant, Sleep, sleep}, +}; use tokio_stream::wrappers::UnboundedReceiverStream; +use uuid::Uuid; -use crate::apresolve::apresolve; -use crate::audio_key::AudioKeyManager; -use crate::authentication::Credentials; -use crate::cache::Cache; -use crate::channel::ChannelManager; -use crate::config::SessionConfig; -use crate::connection::{self, AuthenticationError}; -use crate::mercury::MercuryManager; +const SESSION_DATA_POISON_MSG: &str = "session data rwlock should not be poisoned"; #[derive(Debug, Error)] pub enum SessionError { @@ -31,110 +51,246 @@ pub enum SessionError { AuthenticationError(#[from] AuthenticationError), #[error("Cannot create session: {0}")] IoError(#[from] io::Error), + #[error("Session is not connected")] + NotConnected, + #[error("packet {0} unknown")] + Packet(u8), } +impl From for Error { + fn from(err: SessionError) -> Self { + match err { + SessionError::AuthenticationError(_) => Error::unauthenticated(err), + SessionError::IoError(_) => Error::unavailable(err), + SessionError::NotConnected => Error::unavailable(err), + SessionError::Packet(_) => Error::unimplemented(err), + } + } +} + +impl From for Error { + fn from(err: quick_xml::encoding::EncodingError) -> Self { + Error::invalid_argument(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, + session_id: String, + client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, + connection_id: String, + auth_data: Vec, time_delta: i64, - canonical_username: String, invalid: bool, + user_data: UserData, } struct SessionInternal { config: SessionConfig, data: RwLock, - tx_connection: mpsc::UnboundedSender<(u8, Vec)>, + http_client: HttpClient, + tx_connection: OnceLock)>>, - audio_key: OnceCell, - channel: OnceCell, - mercury: OnceCell, + apresolver: OnceLock, + audio_key: OnceLock, + channel: OnceLock, + mercury: OnceLock, + dealer: OnceLock, + spclient: OnceLock, + token_provider: OnceLock, + login5: OnceLock, cache: Option>, handle: tokio::runtime::Handle, - - session_id: usize, } -static SESSION_COUNTER: AtomicUsize = AtomicUsize::new(0); - +/// A shared reference to a Spotify session. +/// +/// After instantiating, you need to login via [Session::connect]. +/// You can either implement the whole playback logic yourself by using +/// this structs interface directly or hand it to a +/// `Player`. +/// +/// *Note*: [Session] instances cannot yet be reused once invalidated. After +/// an unexpectedly closed connection, you'll need to create a new [Session]. #[derive(Clone)] pub struct Session(Arc); impl Session { - pub async fn connect( - config: SessionConfig, - credentials: Credentials, - cache: Option, - ) -> Result { - let ap = apresolve(config.proxy.as_ref(), config.ap_port).await; + pub fn new(config: SessionConfig, cache: Option) -> Self { + let http_client = HttpClient::new(config.proxy.as_ref()); - info!("Connecting to AP \"{}\"", ap); - let mut conn = connection::connect(ap, config.proxy.as_ref()).await?; + debug!("new Session"); - let reusable_credentials = - connection::authenticate(&mut conn, credentials, &config.device_id).await?; - info!("Authenticated as \"{}\" !", reusable_credentials.username); - if let Some(cache) = &cache { - cache.save_credentials(&reusable_credentials); - } + let session_data = SessionData { + client_id: config.client_id.clone(), + // can be any guid, doesn't need to be simple + session_id: Uuid::new_v4().as_simple().to_string(), + ..SessionData::default() + }; - let session = Session::create( - conn, + Self(Arc::new(SessionInternal { config, - cache, - reusable_credentials.username, - tokio::runtime::Handle::current(), - ); - - Ok(session) + data: RwLock::new(session_data), + http_client, + tx_connection: OnceLock::new(), + cache: cache.map(Arc::new), + apresolver: OnceLock::new(), + audio_key: OnceLock::new(), + channel: OnceLock::new(), + mercury: OnceLock::new(), + dealer: OnceLock::new(), + spclient: OnceLock::new(), + token_provider: OnceLock::new(), + login5: OnceLock::new(), + handle: tokio::runtime::Handle::current(), + })) } - fn create( - transport: connection::Transport, - config: SessionConfig, - cache: Option, - username: String, - handle: tokio::runtime::Handle, - ) -> Session { + async fn connect_inner( + &self, + access_point: &SocketAddress, + credentials: Credentials, + ) -> Result<(Credentials, Transport), Error> { + const MAX_RETRIES: u8 = 1; + let mut transport = connection::connect_with_retry( + &access_point.0, + access_point.1, + self.config().proxy.as_ref(), + MAX_RETRIES, + ) + .await?; + let mut reusable_credentials = connection::authenticate( + &mut transport, + credentials.clone(), + &self.config().device_id, + ) + .await?; + + // Might be able to remove this once keymaster is replaced with login5. + if credentials.auth_type == AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN { + trace!( + "Reconnect using stored credentials as token authed sessions cannot use keymaster." + ); + transport = connection::connect_with_retry( + &access_point.0, + access_point.1, + self.config().proxy.as_ref(), + MAX_RETRIES, + ) + .await?; + reusable_credentials = connection::authenticate( + &mut transport, + reusable_credentials.clone(), + &self.config().device_id, + ) + .await?; + } + + Ok((reusable_credentials, transport)) + } + + pub async fn connect( + &self, + credentials: Credentials, + store_credentials: bool, + ) -> Result<(), Error> { + // There currently happen to be 6 APs but anything will do to avoid an infinite loop. + const MAX_AP_TRIES: u8 = 6; + let mut num_ap_tries = 0; + let (reusable_credentials, transport) = loop { + let ap = self.apresolver().resolve("accesspoint").await?; + info!("Connecting to AP \"{}:{}\"", ap.0, ap.1); + match self.connect_inner(&ap, credentials.clone()).await { + Ok(ct) => break ct, + Err(e) => { + num_ap_tries += 1; + if MAX_AP_TRIES == num_ap_tries { + error!("Tried too many access points"); + return Err(e); + } + if let Some(AuthenticationError::LoginFailed(ErrorCode::TryAnotherAP)) = + e.error.downcast_ref::() + { + warn!("Instructed to try another access point..."); + continue; + } else if let Some(AuthenticationError::LoginFailed(..)) = + e.error.downcast_ref::() + { + return Err(e); + } else { + warn!("Try another access point..."); + continue; + } + } + } + }; + + let username = reusable_credentials + .username + .as_ref() + .map_or("UNKNOWN", |s| s.as_str()); + info!("Authenticated as '{username}' !"); + self.set_username(username); + self.set_auth_data(&reusable_credentials.auth_data); + if let Some(cache) = self.cache() { + if store_credentials { + let cred_changed = cache + .credentials() + .map(|c| c != reusable_credentials) + .unwrap_or(true); + if cred_changed { + cache.save_credentials(&reusable_credentials); + } + } + } + + // This channel serves as a buffer for packets and serializes access to the TcpStream, such + // that `self.send_packet` can return immediately and needs no additional synchronization. + let (tx_connection, rx_connection) = mpsc::unbounded_channel(); + self.0 + .tx_connection + .set(tx_connection) + .map_err(|_| SessionError::NotConnected)?; + let (sink, stream) = transport.split(); - - let (sender_tx, sender_rx) = mpsc::unbounded_channel(); - let session_id = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); - - debug!("new Session[{}]", session_id); - - let session = Session(Arc::new(SessionInternal { - config, - data: RwLock::new(SessionData { - country: String::new(), - canonical_username: username, - invalid: false, - time_delta: 0, - }), - tx_connection: sender_tx, - cache: cache.map(Arc::new), - audio_key: OnceCell::new(), - channel: OnceCell::new(), - mercury: OnceCell::new(), - handle, - session_id, - })); - - let sender_task = UnboundedReceiverStream::new(sender_rx) + let sender_task = UnboundedReceiverStream::new(rx_connection) .map(Ok) .forward(sink); - let receiver_task = DispatchTask(stream, session.weak()); - + let session_weak = self.weak(); tokio::spawn(async move { - let result = future::try_join(sender_task, receiver_task).await; - - if let Err(e) = result { - error!("{}", e); + if let Err(e) = sender_task.await { + error!("{e}"); + if let Some(session) = session_weak.try_upgrade() { + if !session.is_invalid() { + session.shutdown(); + } + } } }); - session + tokio::spawn(DispatchTask::new(self.weak(), stream)); + + Ok(()) + } + + pub fn apresolver(&self) -> &ApResolver { + self.0 + .apresolver + .get_or_init(|| ApResolver::new(self.weak())) } pub fn audio_key(&self) -> &AudioKeyManager { @@ -149,14 +305,44 @@ impl Session { .get_or_init(|| ChannelManager::new(self.weak())) } + pub fn http_client(&self) -> &HttpClient { + &self.0.http_client + } + pub fn mercury(&self) -> &MercuryManager { self.0 .mercury .get_or_init(|| MercuryManager::new(self.weak())) } + pub fn dealer(&self) -> &DealerManager { + self.0 + .dealer + .get_or_init(|| DealerManager::new(self.weak())) + } + + pub fn spclient(&self) -> &SpClient { + self.0.spclient.get_or_init(|| SpClient::new(self.weak())) + } + + pub fn token_provider(&self) -> &TokenProvider { + self.0 + .token_provider + .get_or_init(|| TokenProvider::new(self.weak())) + } + + pub fn login5(&self) -> &Login5Manager { + self.0 + .login5 + .get_or_init(|| Login5Manager::new(self.weak())) + } + pub fn time_delta(&self) -> i64 { - self.0.data.read().unwrap().time_delta + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .time_delta } pub fn spawn(&self, task: T) @@ -169,84 +355,295 @@ impl Session { fn debug_info(&self) { debug!( - "Session[{}] strong={} weak={}", - self.0.session_id, + "Session strong={} weak={}", Arc::strong_count(&self.0), Arc::weak_count(&self.0) ); } - #[allow(clippy::match_same_arms)] - fn dispatch(&self, cmd: u8, data: Bytes) { - match cmd { - 0x4 => { - let server_timestamp = BigEndian::read_u32(data.as_ref()) as i64; - let timestamp = match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(dur) => dur, - Err(err) => err.duration(), - } - .as_secs() as i64; + fn check_catalogue(attributes: &UserAttributes) { + if let Some(account_type) = attributes.get("type") { + if account_type != "premium" { + error!("librespot does not support {account_type:?} accounts."); + info!("Please support Spotify and your artists and sign up for a premium account."); - self.0.data.write().unwrap().time_delta = server_timestamp - timestamp; - - self.debug_info(); - self.send_packet(0x49, vec![0, 0, 0, 0]); + // TODO: logout instead of exiting + exit(1); } - 0x4a => (), - 0x1b => { - let country = String::from_utf8(data.as_ref().to_owned()).unwrap(); - info!("Country: {:?}", country); - self.0.data.write().unwrap().country = country; - } - - 0x9 | 0xa => self.channel().dispatch(cmd, data), - 0xd | 0xe => self.audio_key().dispatch(cmd, data), - 0xb2..=0xb6 => self.mercury().dispatch(cmd, data), - _ => (), } } - pub fn send_packet(&self, cmd: u8, data: Vec) { - self.0.tx_connection.send((cmd, data)).unwrap(); + pub fn send_packet(&self, cmd: PacketType, data: Vec) -> Result<(), Error> { + match self.0.tx_connection.get() { + Some(tx) => Ok(tx.send((cmd as u8, data))?), + None => Err(SessionError::NotConnected.into()), + } } pub fn cache(&self) -> Option<&Arc> { self.0.cache.as_ref() } - fn config(&self) -> &SessionConfig { + pub fn config(&self) -> &SessionConfig { &self.0.config } - pub fn username(&self) -> String { - self.0.data.read().unwrap().canonical_username.clone() + // This clones a fairly large struct, so use a specific getter or setter unless + // you need more fields at once, in which case this can spare multiple `read` + // locks. + pub fn user_data(&self) -> UserData { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .clone() } - pub fn country(&self) -> String { - self.0.data.read().unwrap().country.clone() + pub fn session_id(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .session_id + .clone() + } + + pub fn set_session_id(&self, session_id: &str) { + session_id.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .session_id, + ); } pub fn device_id(&self) -> &str { &self.config().device_id } + pub fn client_id(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .client_id + .clone() + } + + pub fn set_client_id(&self, client_id: &str) { + client_id.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .client_id, + ); + } + + pub fn client_name(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .client_name + .clone() + } + + pub fn set_client_name(&self, client_name: &str) { + client_name.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .client_name, + ); + } + + pub fn client_brand_name(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .client_brand_name + .clone() + } + + pub fn set_client_brand_name(&self, client_brand_name: &str) { + client_brand_name.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .client_brand_name, + ); + } + + pub fn client_model_name(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .client_model_name + .clone() + } + + pub fn set_client_model_name(&self, client_model_name: &str) { + client_model_name.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .client_model_name, + ); + } + + pub fn connection_id(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .connection_id + .clone() + } + + pub fn set_connection_id(&self, connection_id: &str) { + connection_id.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .connection_id, + ); + } + + pub fn username(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .canonical_username + .clone() + } + + pub fn set_username(&self, username: &str) { + username.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .canonical_username, + ); + } + + pub fn auth_data(&self) -> Vec { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .auth_data + .clone() + } + + pub fn set_auth_data(&self, auth_data: &[u8]) { + auth_data.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .auth_data, + ); + } + + pub fn country(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .country + .clone() + } + + pub fn filter_explicit_content(&self) -> bool { + match self.get_user_attribute("filter-explicit-content") { + Some(value) => matches!(&*value, "1"), + None => false, + } + } + + pub fn autoplay(&self) -> bool { + if let Some(overide) = self.config().autoplay { + return overide; + } + + match self.get_user_attribute("autoplay") { + Some(value) => matches!(&*value, "1"), + None => false, + } + } + + 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() + .expect(SESSION_DATA_POISON_MSG) + .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() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .attributes + .extend(attributes) + } + + pub fn get_user_attribute(&self, key: &str) -> Option { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .attributes + .get(key) + .cloned() + } + fn weak(&self) -> SessionWeak { SessionWeak(Arc::downgrade(&self.0)) } - pub fn session_id(&self) -> usize { - self.0.session_id - } - pub fn shutdown(&self) { - debug!("Invalidating session[{}]", self.0.session_id); - self.0.data.write().unwrap().invalid = true; + debug!("Shutdown: Invalidating session"); + self.0.data.write().expect(SESSION_DATA_POISON_MSG).invalid = true; self.mercury().shutdown(); self.channel().shutdown(); } pub fn is_invalid(&self) -> bool { - self.0.data.read().unwrap().invalid + self.0.data.read().expect(SESSION_DATA_POISON_MSG).invalid } } @@ -259,57 +656,302 @@ impl SessionWeak { } pub(crate) fn upgrade(&self) -> Session { - self.try_upgrade().expect("Session died") + self.try_upgrade() + .expect("session was dropped and so should have this component") } } impl Drop for SessionInternal { fn drop(&mut self) { - debug!("drop Session[{}]", self.session_id); + debug!("drop Session"); } } -struct DispatchTask(S, SessionWeak) +#[derive(Clone, Copy, Default, Debug, PartialEq)] +enum KeepAliveState { + #[default] + // Expecting a Ping from the server, either after startup or after a PongAck. + ExpectingPing, + + // We need to send a Pong at the given time. + PendingPong, + + // We just sent a Pong and wait for it be ACK'd. + ExpectingPongAck, +} + +const INITIAL_PING_TIMEOUT: TokioDuration = TokioDuration::from_secs(20); +const PING_TIMEOUT: TokioDuration = TokioDuration::from_secs(80); // 60s expected + 20s buffer +const PONG_DELAY: TokioDuration = TokioDuration::from_secs(60); +const PONG_ACK_TIMEOUT: TokioDuration = TokioDuration::from_secs(20); + +impl KeepAliveState { + fn debug(&self, sleep: &Sleep) { + let delay = sleep + .deadline() + .checked_duration_since(TokioInstant::now()) + .map(|t| t.as_secs_f64()) + .unwrap_or(f64::INFINITY); + + trace!("keep-alive state: {self:?}, timeout in {delay:.1}"); + } +} + +pin_project! { + struct DispatchTask + where + S: TryStream + { + session: SessionWeak, + keep_alive_state: KeepAliveState, + #[pin] + stream: S, + #[pin] + timeout: Sleep, + } + + impl PinnedDrop for DispatchTask + where + S: TryStream + { + fn drop(_this: Pin<&mut Self>) { + debug!("drop Dispatch"); + } + } +} + +impl DispatchTask where - S: TryStream + Unpin; + S: TryStream, +{ + fn new(session: SessionWeak, stream: S) -> Self { + Self { + session, + keep_alive_state: KeepAliveState::ExpectingPing, + stream, + timeout: sleep(INITIAL_PING_TIMEOUT), + } + } + + fn dispatch( + mut self: Pin<&mut Self>, + session: &Session, + cmd: u8, + data: Bytes, + ) -> Result<(), Error> { + use KeepAliveState::*; + use PacketType::*; + + let packet_type = FromPrimitive::from_u8(cmd); + let cmd = match packet_type { + Some(cmd) => cmd, + None => { + trace!("Ignoring unknown packet {cmd:x}"); + return Err(SessionError::Packet(cmd).into()); + } + }; + + match packet_type { + Some(Ping) => { + trace!("Received Ping"); + if self.keep_alive_state != ExpectingPing { + warn!("Received unexpected Ping from server") + } + let mut this = self.as_mut().project(); + *this.keep_alive_state = PendingPong; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PONG_DELAY); + this.keep_alive_state.debug(&this.timeout); + + let server_timestamp = BigEndian::read_u32(data.as_ref()) as i64; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() as i64; + { + let mut data = session.0.data.write().expect(SESSION_DATA_POISON_MSG); + data.time_delta = server_timestamp.saturating_sub(timestamp); + } + + session.debug_info(); + + Ok(()) + } + Some(PongAck) => { + trace!("Received PongAck"); + if self.keep_alive_state != ExpectingPongAck { + warn!("Received unexpected PongAck from server") + } + let mut this = self.as_mut().project(); + *this.keep_alive_state = ExpectingPing; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PING_TIMEOUT); + this.keep_alive_state.debug(&this.timeout); + + Ok(()) + } + Some(CountryCode) => { + let country = String::from_utf8(data.as_ref().to_owned())?; + info!("Country: {country:?}"); + session + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .country = country; + Ok(()) + } + Some(StreamChunkRes) | Some(ChannelError) => session.channel().dispatch(cmd, data), + Some(AesKey) | Some(AesKeyError) => session.audio_key().dispatch(cmd, data), + Some(MercuryReq) | Some(MercurySub) | Some(MercuryUnsub) | Some(MercuryEvent) => { + session.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_into(&mut buf) { + Ok(Event::Start(ref element)) => { + std::str::from_utf8(element)?.clone_into(&mut current_element) + } + 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.xml_content()?.to_string(), + ); + } + } + Ok(Event::Eof) => break, + Ok(_) => (), + Err(e) => warn!( + "Error parsing XML at position {}: {:?}", + reader.buffer_position(), + e + ), + } + } + + trace!("Received product info: {user_attributes:#?}"); + Session::check_catalogue(&user_attributes); + + session + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .attributes = user_attributes; + Ok(()) + } + Some(SecretBlock) + | Some(LegacyWelcome) + | Some(UnknownDataAllZeros) + | Some(LicenseVersion) => Ok(()), + _ => { + trace!("Ignoring {cmd:?} packet with data {data:#?}"); + Err(SessionError::Packet(cmd as u8).into()) + } + } + } +} impl Future for DispatchTask where - S: TryStream + Unpin, + S: TryStream, ::Ok: std::fmt::Debug, { type Output = Result<(), S::Error>; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let session = match self.1.try_upgrade() { + use KeepAliveState::*; + + let session = match self.session.try_upgrade() { Some(session) => session, None => return Poll::Ready(Ok(())), }; + // Process all messages that are immediately ready loop { - let (cmd, data) = match ready!(self.0.try_poll_next_unpin(cx)) { - Some(Ok(t)) => t, - None => { + match self.as_mut().project().stream.try_poll_next(cx) { + Poll::Ready(Some(Ok((cmd, data)))) => { + let result = self.as_mut().dispatch(&session, cmd, data); + if let Err(e) = result { + debug!("could not dispatch command: {e}"); + } + } + Poll::Ready(None) => { warn!("Connection to server closed."); session.shutdown(); return Poll::Ready(Ok(())); } - Some(Err(e)) => { + Poll::Ready(Some(Err(e))) => { + error!("Connection to server closed."); session.shutdown(); return Poll::Ready(Err(e)); } - }; - - session.dispatch(cmd, data); + Poll::Pending => break, + } } - } -} -impl Drop for DispatchTask -where - S: TryStream + Unpin, -{ - fn drop(&mut self) { - debug!("drop Dispatch"); + // Handle the keep-alive sequence, returning an error when we haven't received a + // Ping/PongAck for too long. + // + // The expected keepalive sequence is + // - Server: Ping + // - wait 60s + // - Client: Pong + // - Server: PongAck + // - wait 60s + // - repeat + // + // This means that we silently lost connection to Spotify servers if + // - we don't receive Ping immediately after connecting, + // - we don't receive a Ping 60s after the last PongAck or + // - we don't receive a PongAck immediately after our Pong. + // + // Currently, we add a safety margin of 20s to these expected deadlines. + let mut this = self.as_mut().project(); + if let Poll::Ready(()) = this.timeout.as_mut().poll(cx) { + match this.keep_alive_state { + ExpectingPing | ExpectingPongAck => { + if !session.is_invalid() { + session.shutdown(); + } + // TODO: Optionally reconnect (with cached/last credentials?) + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "session lost connection to server ({:?})", + this.keep_alive_state + ), + ))); + } + PendingPong => { + trace!("Sending Pong"); + // TODO: Ideally, this should flush the `Framed as Sink` + // before starting the timeout. + let _ = session.send_packet(PacketType::Pong, vec![0, 0, 0, 0]); + *this.keep_alive_state = ExpectingPongAck; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PONG_ACK_TIMEOUT); + this.keep_alive_state.debug(&this.timeout); + } + } + } + + Poll::Pending } } diff --git a/core/src/socket.rs b/core/src/socket.rs new file mode 100644 index 00000000..71b0d6b9 --- /dev/null +++ b/core/src/socket.rs @@ -0,0 +1,34 @@ +use std::{io, net::ToSocketAddrs}; + +use tokio::net::TcpStream; +use url::Url; + +use crate::proxytunnel; + +pub async fn connect(host: &str, port: u16, proxy: Option<&Url>) -> io::Result { + let socket = if let Some(proxy_url) = proxy { + info!("Using proxy \"{proxy_url}\""); + + let socket_addr = proxy_url.socket_addrs(|| None).and_then(|addrs| { + addrs.into_iter().next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Can't resolve proxy server address", + ) + }) + })?; + let socket = TcpStream::connect(&socket_addr).await?; + + proxytunnel::proxy_connect(socket, host, &port.to_string()).await? + } else { + let socket_addr = (host, port).to_socket_addrs()?.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Can't resolve access point address", + ) + })?; + + TcpStream::connect(&socket_addr).await? + }; + Ok(socket) +} diff --git a/core/src/spclient.rs b/core/src/spclient.rs new file mode 100644 index 00000000..7bc9d0b5 --- /dev/null +++ b/core/src/spclient.rs @@ -0,0 +1,905 @@ +use std::{ + fmt::Write, + time::{Duration, Instant}, +}; + +use crate::config::{OS, os_version}; +use crate::{ + Error, FileId, SpotifyId, SpotifyUri, + apresolve::SocketAddress, + config::SessionConfig, + error::ErrorKind, + protocol::{ + autoplay_context_request::AutoplayContextRequest, + clienttoken_http::{ + ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType, + ClientTokenResponse, ClientTokenResponseType, + }, + connect::PutStateRequest, + context::Context, + extended_metadata::BatchedEntityRequest, + }, + token::Token, + util, + version::spotify_semantic_version, +}; +use bytes::Bytes; +use data_encoding::HEXUPPER_PERMISSIVE; +use futures_util::future::IntoStream; +use http::{Uri, header::HeaderValue}; +use hyper::{ + HeaderMap, Method, Request, + header::{ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HeaderName, RANGE}, +}; +use hyper_util::client::legacy::ResponseFuture; +use protobuf::{Enum, Message, MessageFull}; +use rand::RngCore; +use sysinfo::System; +use thiserror::Error; + +component! { + SpClient : SpClientInner { + accesspoint: Option = None, + strategy: RequestStrategy = RequestStrategy::default(), + client_token: Option = None, + } +} + +pub type SpClientResult = Result; + +#[allow(clippy::declare_interior_mutable_const)] +pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token"); +#[allow(clippy::declare_interior_mutable_const)] +const CONNECTION_ID: HeaderName = HeaderName::from_static("x-spotify-connection-id"); + +const NO_METRICS_AND_SALT: RequestOptions = RequestOptions { + metrics: false, + salt: false, + base_url: None, +}; + +const SPCLIENT_FALLBACK_ENDPOINT: RequestOptions = RequestOptions { + metrics: true, + salt: true, + base_url: Some("https://spclient.wg.spotify.com"), +}; + +#[derive(Debug, Error)] +pub enum SpClientError { + #[error("missing attribute {0}")] + Attribute(String), + #[error("expected data but received none")] + NoData, +} + +impl From for Error { + fn from(err: SpClientError) -> Self { + Self::failed_precondition(err) + } +} + +#[derive(Copy, Clone, Debug)] +pub enum RequestStrategy { + TryTimes(usize), + Infinitely, +} + +impl Default for RequestStrategy { + fn default() -> Self { + RequestStrategy::TryTimes(10) + } +} + +pub struct RequestOptions { + metrics: bool, + salt: bool, + base_url: Option<&'static str>, +} + +impl Default for RequestOptions { + fn default() -> Self { + Self { + metrics: true, + salt: true, + base_url: None, + } + } +} + +impl SpClient { + pub fn set_strategy(&self, strategy: RequestStrategy) { + self.lock(|inner| inner.strategy = strategy) + } + + pub async fn flush_accesspoint(&self) { + self.lock(|inner| inner.accesspoint = None) + } + + pub async fn get_accesspoint(&self) -> Result { + // Memoize the current access point. + let ap = self.lock(|inner| inner.accesspoint.clone()); + let tuple = match ap { + Some(tuple) => tuple, + None => { + let tuple = self.session().apresolver().resolve("spclient").await?; + self.lock(|inner| inner.accesspoint = Some(tuple.clone())); + info!( + "Resolved \"{}:{}\" as spclient access point", + tuple.0, tuple.1 + ); + tuple + } + }; + Ok(tuple) + } + + pub async fn base_url(&self) -> Result { + let ap = self.get_accesspoint().await?; + Ok(format!("https://{}:{}", ap.0, ap.1)) + } + + async fn client_token_request(&self, message: &M) -> Result { + let body = message.write_to_bytes()?; + + let request = Request::builder() + .method(&Method::POST) + .uri("https://clienttoken.spotify.com/v1/clienttoken") + .header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) + .body(body.into())?; + + self.session().http_client().request_body(request).await + } + + pub async fn client_token(&self) -> Result { + let client_token = self.lock(|inner| { + if let Some(token) = &inner.client_token { + if token.is_expired() { + inner.client_token = None; + } + } + inner.client_token.clone() + }); + + if let Some(client_token) = client_token { + return Ok(client_token.access_token); + } + + debug!("Client token unavailable or expired, requesting new token."); + + let mut request = ClientTokenRequest::new(); + request.request_type = ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST.into(); + + let client_data = request.mut_client_data(); + + client_data.client_version = spotify_semantic_version(); + + // Current state of affairs: keymaster ID works on all tested platforms, but may be phased out, + // so it seems a good idea to mimick the real clients. `self.session().client_id()` returns the + // ID of the client that last connected, but requesting a client token with this ID only works + // on macOS and Windows. On Android and iOS we can send a platform-specific client ID and are + // then presented with a hash cash challenge. On Linux, we have to pass the old keymaster ID. + // We delegate most of this logic to `SessionConfig`. + let os = OS; + let client_id = match os { + "macos" | "windows" => self.session().client_id(), + os => SessionConfig::default_for_os(os).client_id, + }; + client_data.client_id = client_id; + + let connectivity_data = client_data.mut_connectivity_sdk_data(); + connectivity_data.device_id = self.session().device_id().to_string(); + + let platform_data = connectivity_data + .platform_specific_data + .mut_or_insert_default(); + + let os_version = os_version(); + let kernel_version = System::kernel_version().unwrap_or_else(|| String::from("0")); + + match os { + "windows" => { + let os_version = os_version.parse::().unwrap_or(10.) as i32; + let kernel_version = kernel_version.parse::().unwrap_or(21370); + + let (pe, image_file) = match std::env::consts::ARCH { + "arm" => (448, 452), + "aarch64" => (43620, 452), + "x86_64" => (34404, 34404), + _ => (332, 332), // x86 + }; + + let windows_data = platform_data.mut_desktop_windows(); + windows_data.os_version = os_version; + windows_data.os_build = kernel_version; + windows_data.platform_id = 2; + windows_data.unknown_value_6 = 9; + windows_data.image_file_machine = image_file; + windows_data.pe_machine = pe; + windows_data.unknown_value_10 = true; + } + "ios" => { + let ios_data = platform_data.mut_ios(); + ios_data.user_interface_idiom = 0; + ios_data.target_iphone_simulator = false; + ios_data.hw_machine = "iPhone14,5".to_string(); + ios_data.system_version = os_version; + } + "android" => { + let android_data = platform_data.mut_android(); + android_data.android_version = os_version; + android_data.api_version = 31; + "Pixel".clone_into(&mut android_data.device_name); + "GF5KQ".clone_into(&mut android_data.model_str); + "Google".clone_into(&mut android_data.vendor); + } + "macos" => { + let macos_data = platform_data.mut_desktop_macos(); + macos_data.system_version = os_version; + macos_data.hw_model = "iMac21,1".to_string(); + macos_data.compiled_cpu_type = std::env::consts::ARCH.to_string(); + } + _ => { + let linux_data = platform_data.mut_desktop_linux(); + linux_data.system_name = "Linux".to_string(); + linux_data.system_release = kernel_version; + linux_data.system_version = os_version; + linux_data.hardware = std::env::consts::ARCH.to_string(); + } + } + + let mut response = self.client_token_request(&request).await?; + let mut count = 0; + const MAX_TRIES: u8 = 3; + + let token_response = loop { + count += 1; + + let message = ClientTokenResponse::parse_from_bytes(&response)?; + + match ClientTokenResponseType::from_i32(message.response_type.value()) { + // depending on the platform, you're either given a token immediately + // or are presented a hash cash challenge to solve first + Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => { + debug!("Received a granted token"); + break message; + } + Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => { + debug!("Received a hash cash challenge, solving..."); + + let challenges = message.challenges().clone(); + let state = challenges.state; + if let Some(challenge) = challenges.challenges.first() { + let hash_cash_challenge = challenge.evaluate_hashcash_parameters(); + + let ctx = vec![]; + let prefix = HEXUPPER_PERMISSIVE + .decode(hash_cash_challenge.prefix.as_bytes()) + .map_err(|e| { + Error::failed_precondition(format!( + "Unable to decode hash cash challenge: {e}" + )) + })?; + let length = hash_cash_challenge.length; + + let mut suffix = [0u8; 0x10]; + let answer = util::solve_hash_cash(&ctx, &prefix, length, &mut suffix); + + match answer { + Ok(_) => { + // the suffix must be in uppercase + let suffix = HEXUPPER_PERMISSIVE.encode(&suffix); + + let mut answer_message = ClientTokenRequest::new(); + answer_message.request_type = + ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST + .into(); + + let challenge_answers = answer_message.mut_challenge_answers(); + + let mut challenge_answer = ChallengeAnswer::new(); + challenge_answer.mut_hash_cash().suffix = suffix; + challenge_answer.ChallengeType = + ChallengeType::CHALLENGE_HASH_CASH.into(); + + challenge_answers.state = state.to_string(); + challenge_answers.answers.push(challenge_answer); + + trace!("Answering hash cash challenge"); + match self.client_token_request(&answer_message).await { + Ok(token) => { + response = token; + continue; + } + Err(e) => { + trace!("Answer not accepted {count}/{MAX_TRIES}: {e}"); + } + } + } + Err(e) => trace!( + "Unable to solve hash cash challenge {count}/{MAX_TRIES}: {e}" + ), + } + + if count < MAX_TRIES { + response = self.client_token_request(&request).await?; + } else { + return Err(Error::failed_precondition(format!( + "Unable to solve any of {MAX_TRIES} hash cash challenges" + ))); + } + } else { + return Err(Error::failed_precondition("No challenges found")); + } + } + + Some(unknown) => { + return Err(Error::unimplemented(format!( + "Unknown client token response type: {unknown:?}" + ))); + } + None => return Err(Error::failed_precondition("No client token response type")), + } + }; + + let granted_token = token_response.granted_token(); + let access_token = granted_token.token.to_owned(); + + self.lock(|inner| { + let client_token = Token { + access_token: access_token.clone(), + expires_in: Duration::from_secs( + granted_token + .refresh_after_seconds + .try_into() + .unwrap_or(7200), + ), + token_type: "client-token".to_string(), + scopes: granted_token + .domains + .iter() + .map(|d| d.domain.clone()) + .collect(), + timestamp: Instant::now(), + }; + + inner.client_token = Some(client_token); + }); + + trace!("Got client token: {granted_token:?}"); + + Ok(access_token) + } + + pub async fn request_with_protobuf( + &self, + method: &Method, + endpoint: &str, + headers: Option, + message: &M, + ) -> SpClientResult { + self.request_with_protobuf_and_options( + method, + endpoint, + headers, + message, + &Default::default(), + ) + .await + } + + pub async fn request_with_protobuf_and_options( + &self, + method: &Method, + endpoint: &str, + headers: Option, + message: &M, + options: &RequestOptions, + ) -> SpClientResult { + let body = message.write_to_bytes()?; + + let mut headers = headers.unwrap_or_default(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/x-protobuf"), + ); + + self.request_with_options(method, endpoint, Some(headers), Some(&body), options) + .await + } + + pub async fn request_as_json( + &self, + method: &Method, + endpoint: &str, + headers: Option, + body: Option<&str>, + ) -> SpClientResult { + let mut headers = headers.unwrap_or_default(); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + + self.request(method, endpoint, Some(headers), body.map(|s| s.as_bytes())) + .await + } + + pub async fn request( + &self, + method: &Method, + endpoint: &str, + headers: Option, + body: Option<&[u8]>, + ) -> SpClientResult { + self.request_with_options(method, endpoint, headers, body, &Default::default()) + .await + } + + pub async fn request_with_options( + &self, + method: &Method, + endpoint: &str, + headers: Option, + body: Option<&[u8]>, + options: &RequestOptions, + ) -> SpClientResult { + let mut tries: usize = 0; + let mut last_response; + + let body = body.unwrap_or_default(); + + loop { + tries += 1; + + // Reconnection logic: retrieve the endpoint every iteration, so we can try + // another access point when we are experiencing network issues (see below). + let mut url = match options.base_url { + Some(base_url) => base_url.to_string(), + None => self.base_url().await?, + }; + url.push_str(endpoint); + + // Add metrics. There is also an optional `partner` key with a value like + // `vodafone-uk` but we've yet to discover how we can find that value. + // For the sake of documentation you could also do "product=free" but + // we only support premium anyway. + if options.metrics && !url.contains("product=0") { + let _ = write!( + url, + "{}product=0&country={}", + util::get_next_query_separator(&url), + self.session().country() + ); + } + + // Defeat caches. Spotify-generated URLs already contain this. + if options.salt && !url.contains("salt=") { + let _ = write!( + url, + "{}salt={}", + util::get_next_query_separator(&url), + rand::rng().next_u32() + ); + } + + let mut request = Request::builder() + .method(method) + .uri(url) + .header(CONTENT_LENGTH, body.len()) + .body(Bytes::copy_from_slice(body))?; + + // Reconnection logic: keep getting (cached) tokens because they might have expired. + let token = self.session().login5().auth_token().await?; + + let headers_mut = request.headers_mut(); + if let Some(ref headers) = headers { + for (name, value) in headers { + headers_mut.insert(name, value.clone()); + } + } + + headers_mut.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("{} {}", token.token_type, token.access_token,))?, + ); + + match self.client_token().await { + Ok(client_token) => { + let _ = headers_mut.insert(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?); + } + Err(e) => { + // currently these endpoints seem to work fine without it + warn!("Unable to get client token: {e} Trying to continue without...") + } + } + + 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 + } + } + + debug!("Error was: {last_response:?}"); + } + + last_response + } + + pub async fn put_connect_state_request(&self, state: &PutStateRequest) -> SpClientResult { + let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id()); + + let mut headers = HeaderMap::new(); + headers.insert(CONNECTION_ID, self.session().connection_id().parse()?); + + self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state) + .await + } + + pub async fn delete_connect_state_request(&self) -> SpClientResult { + let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id()); + self.request(&Method::DELETE, &endpoint, None, None).await + } + + pub async fn put_connect_state_inactive(&self, notify: bool) -> SpClientResult { + let endpoint = format!( + "/connect-state/v1/devices/{}/inactive?notify={notify}", + self.session().device_id() + ); + + let mut headers = HeaderMap::new(); + headers.insert(CONNECTION_ID, self.session().connection_id().parse()?); + + self.request(&Method::PUT, &endpoint, Some(headers), None) + .await + } + + pub async fn get_metadata(&self, scope: &str, id: &SpotifyId) -> SpClientResult { + let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?); + // For unknown reasons, metadata requests must now be sent through spclient.wg.spotify.com. + // Otherwise, the API will respond with 500 Internal Server Error responses. + // Context: https://github.com/librespot-org/librespot/issues/1527 + self.request_with_options( + &Method::GET, + &endpoint, + None, + None, + &SPCLIENT_FALLBACK_ENDPOINT, + ) + .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/v2/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 + } + + pub async fn get_playlist(&self, playlist_id: &SpotifyId) -> SpClientResult { + let endpoint = format!("/playlist/v2/playlist/{}", playlist_id.to_base62()?); + + self.request(&Method::GET, &endpoint, None, None).await + } + + pub async fn get_user_profile( + &self, + username: &str, + playlist_limit: Option, + artist_limit: Option, + ) -> SpClientResult { + let mut endpoint = format!("/user-profile-view/v3/profile/{username}"); + + if playlist_limit.is_some() || artist_limit.is_some() { + let _ = write!(endpoint, "?"); + + if let Some(limit) = playlist_limit { + let _ = write!(endpoint, "playlist_limit={limit}"); + if artist_limit.is_some() { + let _ = write!(endpoint, "&"); + } + } + + if let Some(limit) = artist_limit { + let _ = write!(endpoint, "artist_limit={limit}"); + } + } + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_user_followers(&self, username: &str) -> SpClientResult { + let endpoint = format!("/user-profile-view/v3/profile/{username}/followers"); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_user_following(&self, username: &str) -> SpClientResult { + let endpoint = format!("/user-profile-view/v3/profile/{username}/following"); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_radio_for_track(&self, track_uri: &SpotifyUri) -> SpClientResult { + let endpoint = format!( + "/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json", + track_uri.to_uri()? + ); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + // Known working scopes: stations, tracks + // For others see: https://gist.github.com/roderickvd/62df5b74d2179a12de6817a37bb474f9 + // + // Seen-in-the-wild but unimplemented query parameters: + // - image_style=gradient_overlay + // - excludeClusters=true + // - language=en + // - count_tracks=0 + // - market=from_token + pub async fn get_apollo_station( + &self, + scope: &str, + context_uri: &str, + count: Option, + previous_tracks: Vec, + autoplay: bool, + ) -> SpClientResult { + let mut endpoint = format!("/radio-apollo/v3/{scope}/{context_uri}?autoplay={autoplay}"); + + // Spotify has a default of 50 + if let Some(count) = count { + let _ = write!(endpoint, "&count={count}"); + } + + let previous_track_str = previous_tracks + .iter() + .map(|track| track.to_base62()) + .collect::, _>>()? + .join(","); + // better than checking `previous_tracks.len() > 0` because the `filter_map` could still return 0 items + if !previous_track_str.is_empty() { + let _ = write!(endpoint, "&prev_tracks={previous_track_str}"); + } + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_next_page(&self, next_page_uri: &str) -> SpClientResult { + let endpoint = next_page_uri.trim_start_matches("hm:/"); + self.request_as_json(&Method::GET, endpoint, None, None) + .await + } + + // TODO: Seen-in-the-wild but unimplemented endpoints + // - /presence-view/v1/buddylist + + 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: U, + offset: usize, + length: usize, + ) -> Result, Error> + where + U: TryInto, + >::Error: Into, + { + let req = Request::builder() + .method(&Method::GET) + .uri(cdn_url) + .header( + RANGE, + HeaderValue::from_str(&format!("bytes={}-{}", offset, offset + length - 1))?, + ) + .body(Bytes::new())?; + + let stream = self.session().http_client().request_stream(req)?; + + Ok(stream) + } + + pub async fn request_url(&self, url: &str) -> SpClientResult { + let request = Request::builder() + .method(&Method::GET) + .uri(url) + .body(Bytes::new())?; + + self.session().http_client().request_body(request).await + } + + // Audio preview in 96 kbps MP3, unencrypted + pub async fn get_audio_preview(&self, preview_id: &FileId) -> SpClientResult { + let attribute = "audio-preview-url-template"; + let template = self + .session() + .get_user_attribute(attribute) + .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?; + + let mut url = template.replace("{id}", &preview_id.to_base16()?); + let separator = match url.find('?') { + Some(_) => "&", + None => "?", + }; + let _ = write!(url, "{}cid={}", separator, self.session().client_id()); + + self.request_url(&url).await + } + + // The first 128 kB of a track, unencrypted + pub async fn get_head_file(&self, file_id: &FileId) -> SpClientResult { + let attribute = "head-files-url"; + let template = self + .session() + .get_user_attribute(attribute) + .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?; + + let url = template.replace("{file_id}", &file_id.to_base16()?); + + self.request_url(&url).await + } + + pub async fn get_image(&self, image_id: &FileId) -> SpClientResult { + let attribute = "image-url"; + let template = self + .session() + .get_user_attribute(attribute) + .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?; + let url = template.replace("{file_id}", &image_id.to_base16()?); + + self.request_url(&url).await + } + + /// Request the context for an uri + /// + /// All [SpotifyId] uris are supported in addition to the following special uris: + /// - liked songs: + /// - all: `spotify:user::collection` + /// - of artist: `spotify:user::collection:artist:` + /// - search: `spotify:search:` (whitespaces are replaced with `+`) + /// + /// ## Query params found in the wild: + /// - include_video=true + /// + /// ## Known results of uri types: + /// - uris of type `track` + /// - returns a single page with a single track + /// - when requesting a single track with a query in the request, the returned track uri + /// **will** contain the query + /// - uris of type `artist` + /// - returns 2 pages with tracks: 10 most popular tracks and latest/popular album + /// - remaining pages are artist albums sorted by popularity (only provided as page_url) + /// - uris of type `search` + /// - is massively influenced by the provided query + /// - the query result shown by the search expects no query at all + /// - uri looks like `spotify:search:never+gonna` + pub async fn get_context(&self, uri: &str) -> Result { + let uri = format!("/context-resolve/v1/{uri}"); + + let res = self + .request_with_options(&Method::GET, &uri, None, None, &NO_METRICS_AND_SALT) + .await?; + let ctx_json = String::from_utf8(res.to_vec())?; + if ctx_json.is_empty() { + Err(SpClientError::NoData)? + } + + let ctx = protobuf_json_mapping::parse_from_str::(&ctx_json); + + if ctx.is_err() { + trace!("failed parsing context: {ctx_json}") + } + + Ok(ctx?) + } + + pub async fn get_autoplay_context( + &self, + context_request: &AutoplayContextRequest, + ) -> Result { + let res = self + .request_with_protobuf_and_options( + &Method::POST, + "/context-resolve/v1/autoplay", + None, + context_request, + &NO_METRICS_AND_SALT, + ) + .await?; + + let ctx_json = String::from_utf8(res.to_vec())?; + if ctx_json.is_empty() { + Err(SpClientError::NoData)? + } + + let ctx = protobuf_json_mapping::parse_from_str::(&ctx_json); + + if ctx.is_err() { + trace!("failed parsing context: {ctx_json}") + } + + Ok(ctx?) + } + + pub async fn get_rootlist(&self, from: usize, length: Option) -> SpClientResult { + let length = length.unwrap_or(120); + let user = self.session().username(); + let endpoint = format!( + "/playlist/v2/user/{user}/rootlist?decorate=revision,attributes,length,owner,capabilities,status_code&from={from}&length={length}" + ); + + self.request(&Method::GET, &endpoint, None, None).await + } +} diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index e6e2bae0..c627b551 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -1,43 +1,32 @@ -#![allow(clippy::wrong_self_convention)] - -use std::convert::TryInto; use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SpotifyAudioType { - Track, - Podcast, - NonPlayable, -} +use thiserror::Error; -impl From<&str> for SpotifyAudioType { - fn from(v: &str) -> Self { - match v { - "track" => SpotifyAudioType::Track, - "episode" => SpotifyAudioType::Podcast, - _ => SpotifyAudioType::NonPlayable, - } - } -} +use crate::{Error, SpotifyUri}; -impl From for &str { - fn from(audio_type: SpotifyAudioType) -> &'static str { - match audio_type { - SpotifyAudioType::Track => "track", - SpotifyAudioType::Podcast => "episode", - SpotifyAudioType::NonPlayable => "unknown", - } - } -} +// re-export FileId for historic reasons, when it was part of this mod +pub use crate::FileId; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct SpotifyId { pub id: u128, - pub audio_type: SpotifyAudioType, } -#[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 ID")] + InvalidFormat, +} + +impl From for Error { + fn from(err: SpotifyIdError) -> Self { + Error::invalid_argument(err) + } +} + +pub type SpotifyIdResult = Result; const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef"; @@ -47,41 +36,40 @@ impl SpotifyId { const SIZE_BASE16: usize = 32; const SIZE_BASE62: usize = 22; - fn track(n: u128) -> SpotifyId { - SpotifyId { - id: n, - audio_type: SpotifyAudioType::Track, - } - } - /// Parses a base16 (hex) encoded [Spotify ID] into a `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 { + /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn from_base16(src: &str) -> SpotifyIdResult { + if src.len() != 32 { + return Err(SpotifyIdError::InvalidId.into()); + } 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 }) } - /// 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 { + /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn from_base62(src: &str) -> SpotifyIdResult { + if src.len() != Self::SIZE_BASE62 { + return Err(SpotifyIdError::InvalidId.into()); + } let mut dst: u128 = 0; for c in src.as_bytes() { @@ -89,62 +77,41 @@ 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; + dst = dst.checked_mul(62).ok_or(SpotifyIdError::InvalidId)?; + dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?; } - Ok(SpotifyId::track(dst)) + Ok(Self { id: dst }) } - /// 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), + }), + Err(_) => Err(SpotifyIdError::InvalidId.into()), } } - /// Parses a [Spotify URI] into a `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. - /// - /// [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)?; - - if src.len() <= SpotifyId::SIZE_BASE62 { - return Err(SpotifyIdError); - } - - let colon_index = src.len() - SpotifyId::SIZE_BASE62 - 1; - - if src.as_bytes()[colon_index] != b':' { - return Err(SpotifyIdError); - } - - let mut id = SpotifyId::from_base62(&src[colon_index + 1..])?; - id.audio_type = src[..colon_index].into(); - - Ok(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]) + #[allow(clippy::wrong_self_convention)] + pub fn to_base16(&self) -> Result { + to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16]) } /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22) /// character long `String`. /// - /// [canonically]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn to_base62(&self) -> String { + /// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + #[allow(clippy::wrong_self_convention)] + pub fn to_base62(&self) -> Result { let mut dst = [0u8; 22]; let mut i = 0; let n = self.id; @@ -182,63 +149,77 @@ impl SpotifyId { dst.reverse(); - unsafe { - // Safety: We are only dealing with ASCII characters. - String::from_utf8_unchecked(dst.to_vec()) - } + String::from_utf8(dst.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into()) } /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in /// big-endian order. - pub fn to_raw(&self) -> [u8; SpotifyId::SIZE] { + #[allow(clippy::wrong_self_convention)] + pub fn to_raw(&self) -> [u8; Self::SIZE] { self.id.to_be_bytes() } +} - /// Returns the `SpotifyId` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`, - /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded - /// Spotify ID. - /// - /// If the `SpotifyId` has an associated type unrecognized by the library, `{type}` will - /// be encoded as `unknown`. - /// - /// [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()); - dst.push_str("spotify:"); - dst.push_str(audio_type); - dst.push(':'); - dst.push_str(&self.to_base62()); - - dst +impl fmt::Debug for SpotifyId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SpotifyId") + .field(&self.to_base62().unwrap_or_else(|_| "invalid uri".into())) + .finish() } } -#[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::Display for SpotifyId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_base62().unwrap_or_else(|_| "invalid uri".into())) } } -impl fmt::Debug for FileId { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_tuple("FileId").field(&self.to_base16()).finish() +impl TryFrom<&[u8]> for SpotifyId { + type Error = crate::Error; + fn try_from(src: &[u8]) -> Result { + Self::from_raw(src) } } -impl fmt::Display for FileId { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(&self.to_base16()) +impl TryFrom<&str> for SpotifyId { + type Error = crate::Error; + fn try_from(src: &str) -> Result { + Self::from_base62(src) } } -#[inline] -fn to_base16(src: &[u8], buf: &mut [u8]) -> String { +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<&SpotifyUri> for SpotifyId { + type Error = crate::Error; + fn try_from(value: &SpotifyUri) -> Result { + match value { + SpotifyUri::Album { id } + | SpotifyUri::Artist { id } + | SpotifyUri::Episode { id } + | SpotifyUri::Playlist { id, .. } + | SpotifyUri::Show { id } + | SpotifyUri::Track { id } => Ok(*id), + SpotifyUri::Local { .. } | SpotifyUri::Unknown { .. } => { + Err(SpotifyIdError::InvalidFormat.into()) + } + } + } +} + +pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result { let mut i = 0; for v in src { buf[i] = BASE16_DIGITS[(v >> 4) as usize]; @@ -246,10 +227,7 @@ fn to_base16(src: &[u8], buf: &mut [u8]) -> String { i += 2; } - unsafe { - // Safety: We are only dealing with ASCII characters. - String::from_utf8_unchecked(buf.to_vec()) - } + String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into()) } #[cfg(test)] @@ -258,18 +236,14 @@ mod tests { struct ConversionCase { id: u128, - kind: SpotifyAudioType, - uri: &'static str, base16: &'static str, base62: &'static str, raw: &'static [u8], } - static CONV_VALID: [ConversionCase; 4] = [ + static CONV_VALID: [ConversionCase; 5] = [ ConversionCase { id: 238762092608182713602505436543891614649, - kind: SpotifyAudioType::Track, - uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH", base16: "b39fe8081e1f4c54be38e8d6f9f12bb9", base62: "5sWHDYs0csV6RS48xBl0tH", raw: &[ @@ -278,8 +252,6 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::Track, - uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ @@ -288,8 +260,6 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::Podcast, - uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ @@ -298,22 +268,23 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::NonPlayable, - uri: "spotify:unknown:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216, ], }, + ConversionCase { + id: 0, + base16: "00000000000000000000000000000000", + base62: "0000000000000000000000", + raw: &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, ]; - static CONV_INVALID: [ConversionCase; 3] = [ + static CONV_INVALID: [ConversionCase; 5] = [ ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, - // Invalid ID in the URI. - uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH", base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9", base62: "!!!!!Ys0csV6RS48xBl0tH", raw: &[ @@ -323,9 +294,6 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, - // Missing colon between ID and type. - uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", base16: "--------------------", base62: "....................", raw: &[ @@ -335,11 +303,30 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, - // Uri too short - uri: "spotify:azb:aRS48xBl0tH", + // too long, should return error but not panic overflow + base16: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + // too long, should return error but not panic overflow + base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + raw: &[ + // Invalid length. + 154, 27, 28, 251, + ], + }, + ConversionCase { + id: 0, base16: "--------------------", - base62: "....................", + // too short to encode a 128 bits int + base62: "aa", + raw: &[ + // Invalid length. + 154, 27, 28, 251, + ], + }, + ConversionCase { + id: 0, + base16: "--------------------", + // too high of a value, this would need a 132 bits int + base62: "ZZZZZZZZZZZZZZZZZZZZZZ", raw: &[ // Invalid length. 154, 27, 28, 251, @@ -354,19 +341,16 @@ mod tests { } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_base62(c.base62), Err(SpotifyIdError)); + assert!(SpotifyId::from_base62(c.base62).is_err(),); } } #[test] fn to_base62() { for c in &CONV_VALID { - let id = SpotifyId { - id: c.id, - audio_type: c.kind, - }; + let id = SpotifyId { id: c.id }; - assert_eq!(id.to_base62(), c.base62); + assert_eq!(id.to_base62().unwrap(), c.base62); } } @@ -377,45 +361,16 @@ mod tests { } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_base16(c.base16), Err(SpotifyIdError)); + assert!(SpotifyId::from_base16(c.base16).is_err(),); } } #[test] fn to_base16() { for c in &CONV_VALID { - let id = SpotifyId { - id: c.id, - audio_type: c.kind, - }; + let id = SpotifyId { id: c.id }; - assert_eq!(id.to_base16(), c.base16); - } - } - - #[test] - fn from_uri() { - for c in &CONV_VALID { - let actual = SpotifyId::from_uri(c.uri).unwrap(); - - assert_eq!(actual.id, c.id); - assert_eq!(actual.audio_type, c.kind); - } - - for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_uri(c.uri), Err(SpotifyIdError)); - } - } - - #[test] - fn to_uri() { - for c in &CONV_VALID { - let id = SpotifyId { - id: c.id, - audio_type: c.kind, - }; - - assert_eq!(id.to_uri(), c.uri); + assert_eq!(id.to_base16().unwrap(), c.base16); } } @@ -426,7 +381,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/spotify_uri.rs b/core/src/spotify_uri.rs new file mode 100644 index 00000000..647ec652 --- /dev/null +++ b/core/src/spotify_uri.rs @@ -0,0 +1,583 @@ +use crate::{Error, SpotifyId}; +use std::{borrow::Cow, fmt}; +use thiserror::Error; + +use librespot_protocol as protocol; + +const SPOTIFY_ITEM_TYPE_ALBUM: &str = "album"; +const SPOTIFY_ITEM_TYPE_ARTIST: &str = "artist"; +const SPOTIFY_ITEM_TYPE_EPISODE: &str = "episode"; +const SPOTIFY_ITEM_TYPE_PLAYLIST: &str = "playlist"; +const SPOTIFY_ITEM_TYPE_SHOW: &str = "show"; +const SPOTIFY_ITEM_TYPE_TRACK: &str = "track"; +const SPOTIFY_ITEM_TYPE_LOCAL: &str = "local"; +const SPOTIFY_ITEM_TYPE_UNKNOWN: &str = "unknown"; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SpotifyUriError { + #[error("not a valid Spotify URI")] + InvalidFormat, + #[error("URI does not belong to Spotify")] + InvalidRoot, +} + +impl From for Error { + fn from(err: SpotifyUriError) -> Self { + Error::invalid_argument(err) + } +} + +pub type SpotifyUriResult = Result; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub enum SpotifyUri { + Album { + id: SpotifyId, + }, + Artist { + id: SpotifyId, + }, + Episode { + id: SpotifyId, + }, + Playlist { + user: Option, + id: SpotifyId, + }, + Show { + id: SpotifyId, + }, + Track { + id: SpotifyId, + }, + Local { + artist: String, + album_title: String, + track_title: String, + duration: std::time::Duration, + }, + Unknown { + kind: Cow<'static, str>, + id: String, + }, +} + +impl SpotifyUri { + /// Returns whether this `SpotifyUri` is for a playable audio item, if known. + pub fn is_playable(&self) -> bool { + matches!(self, SpotifyUri::Episode { .. } | SpotifyUri::Track { .. }) + } + + /// Gets the item type of this URI as a static string + pub fn item_type(&self) -> &'static str { + match &self { + SpotifyUri::Album { .. } => SPOTIFY_ITEM_TYPE_ALBUM, + SpotifyUri::Artist { .. } => SPOTIFY_ITEM_TYPE_ARTIST, + SpotifyUri::Episode { .. } => SPOTIFY_ITEM_TYPE_EPISODE, + SpotifyUri::Playlist { .. } => SPOTIFY_ITEM_TYPE_PLAYLIST, + SpotifyUri::Show { .. } => SPOTIFY_ITEM_TYPE_SHOW, + SpotifyUri::Track { .. } => SPOTIFY_ITEM_TYPE_TRACK, + SpotifyUri::Local { .. } => SPOTIFY_ITEM_TYPE_LOCAL, + SpotifyUri::Unknown { .. } => SPOTIFY_ITEM_TYPE_UNKNOWN, + } + } + + /// Gets the ID of this URI. The resource ID is the component of the URI that identifies + /// the resource after its type label. If `self` is a named ID, the user will be omitted. + pub fn to_id(&self) -> Result { + match &self { + SpotifyUri::Album { id } + | SpotifyUri::Artist { id } + | SpotifyUri::Episode { id } + | SpotifyUri::Playlist { id, .. } + | SpotifyUri::Show { id } + | SpotifyUri::Track { id } => id.to_base62(), + SpotifyUri::Local { + artist, + album_title, + track_title, + duration, + } => { + let duration_secs = duration.as_secs(); + Ok(format!( + "{artist}:{album_title}:{track_title}:{duration_secs}" + )) + } + SpotifyUri::Unknown { id, .. } => Ok(id.clone()), + } + } + + /// Parses a [Spotify URI] into a `SpotifyUri`. + /// + /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}` + /// can be arbitrary while `{id}` is in a format that varies based on the `{type}`: + /// + /// - For most item types, a 22-character long, base62 encoded Spotify ID is expected. + /// - For local files, an arbitrary length string with the fields + /// `{artist}:{album_title}:{track_title}:{duration_in_seconds}` is expected. + /// + /// Spotify URI: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn from_uri(src: &str) -> SpotifyUriResult { + // Basic: `spotify:{type}:{id}` + // Named: `spotify:user:{user}:{type}:{id}` + // Local: `spotify:local:{artist}:{album_title}:{track_title}:{duration_in_seconds}` + let mut parts = src.split(':'); + + let scheme = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + + if scheme != "spotify" { + return Err(SpotifyUriError::InvalidRoot.into()); + } + + let mut username: Option = None; + + let item_type = { + let next = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + if next == "user" { + username.replace( + parts + .next() + .ok_or(SpotifyUriError::InvalidFormat)? + .to_owned(), + ); + parts.next().ok_or(SpotifyUriError::InvalidFormat)? + } else { + next + } + }; + + let name = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + match item_type { + SPOTIFY_ITEM_TYPE_ALBUM => Ok(Self::Album { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_ARTIST => Ok(Self::Artist { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_EPISODE => Ok(Self::Episode { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_PLAYLIST => Ok(Self::Playlist { + id: SpotifyId::from_base62(name)?, + user: username, + }), + SPOTIFY_ITEM_TYPE_SHOW => Ok(Self::Show { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_TRACK => Ok(Self::Track { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_LOCAL => Ok(Self::Local { + artist: "unimplemented".to_owned(), + album_title: "unimplemented".to_owned(), + track_title: "unimplemented".to_owned(), + duration: Default::default(), + }), + _ => Ok(Self::Unknown { + kind: item_type.to_owned().into(), + id: name.to_owned(), + }), + } + } + + /// Returns the `SpotifyUri` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`, + /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded + /// Spotify ID. + /// + /// If the `SpotifyUri` has an associated type unrecognized by the library, `{type}` will + /// be encoded as `unknown`. + /// + /// If the `SpotifyUri` is named, it will be returned in the form + /// `spotify:user:{user}:{type}:{id}`. + /// + /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn to_uri(&self) -> Result { + let item_type = self.item_type(); + let name = self.to_id()?; + + if let SpotifyUri::Playlist { + id, + user: Some(user), + } = self + { + Ok(format!("spotify:user:{user}:{item_type}:{id}")) + } else { + Ok(format!("spotify:{item_type}:{name}")) + } + } + + /// Gets the name of this URI. The resource name is the component of the URI that identifies + /// the resource after its type label. If `self` is a named ID, the user will be omitted. + /// + /// Deprecated: not all IDs can be represented in Base62, so this function has been renamed to + /// [SpotifyUri::to_id], which this implementation forwards to. + #[deprecated(since = "0.8.0", note = "use to_name instead")] + pub fn to_base62(&self) -> Result { + self.to_id() + } +} + +impl fmt::Debug for SpotifyUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SpotifyUri") + .field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) + .finish() + } +} + +impl fmt::Display for SpotifyUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) + } +} + +impl TryFrom<&protocol::metadata::Album> for SpotifyUri { + type Error = crate::Error; + fn try_from(album: &protocol::metadata::Album) -> Result { + Ok(Self::Album { + id: SpotifyId::from_raw(album.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Artist> for SpotifyUri { + type Error = crate::Error; + fn try_from(artist: &protocol::metadata::Artist) -> Result { + Ok(Self::Artist { + id: SpotifyId::from_raw(artist.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Episode> for SpotifyUri { + type Error = crate::Error; + fn try_from(episode: &protocol::metadata::Episode) -> Result { + Ok(Self::Episode { + id: SpotifyId::from_raw(episode.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Track> for SpotifyUri { + type Error = crate::Error; + fn try_from(track: &protocol::metadata::Track) -> Result { + Ok(Self::Track { + id: SpotifyId::from_raw(track.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Show> for SpotifyUri { + type Error = crate::Error; + fn try_from(show: &protocol::metadata::Show) -> Result { + Ok(Self::Show { + id: SpotifyId::from_raw(show.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyUri { + type Error = crate::Error; + fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result { + Ok(Self::Artist { + id: SpotifyId::from_raw(artist.artist_gid())?, + }) + } +} + +impl TryFrom<&protocol::playlist4_external::Item> for SpotifyUri { + type Error = crate::Error; + fn try_from(item: &protocol::playlist4_external::Item) -> Result { + Self::from_uri(item.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 SpotifyUri { + type Error = crate::Error; + fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result { + Ok(Self::Unknown { + kind: "MetaItem".into(), + id: SpotifyId::try_from(item.revision())?.to_base62()?, + }) + } +} + +// Note that this is the unique revision of a playlist, not the ID of that playlist. +impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyUri { + type Error = crate::Error; + fn try_from( + playlist: &protocol::playlist4_external::SelectedListContent, + ) -> Result { + Ok(Self::Unknown { + kind: "SelectedListContent".into(), + id: SpotifyId::try_from(playlist.revision())?.to_base62()?, + }) + } +} + +// 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 SpotifyUri { + type Error = crate::Error; + fn try_from( + picture: &protocol::playlist_annotate3::TranscodedPicture, + ) -> Result { + Ok(Self::Unknown { + kind: "TranscodedPicture".into(), + id: picture.uri().to_owned(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ConversionCase { + parsed: SpotifyUri, + uri: &'static str, + base62: &'static str, + } + + static CONV_VALID: [ConversionCase; 4] = [ + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { + id: 238762092608182713602505436543891614649, + }, + }, + uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH", + base62: "5sWHDYs0csV6RS48xBl0tH", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ConversionCase { + parsed: SpotifyUri::Episode { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ConversionCase { + parsed: SpotifyUri::Show { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ]; + + static CONV_INVALID: [ConversionCase; 5] = [ + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Invalid ID in the URI. + uri: "spotify:track:5sWHDYs0Bl0tH", + base62: "!!!!!Ys0csV6RS48xBl0tH", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Missing colon between ID and type. + uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", + base62: "....................", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Uri too short + uri: "spotify:track:aRS48xBl0tH", + // too long, should return error but not panic overflow + base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Uri too short + uri: "spotify:track:aRS48xBl0tH", + // too short to encode a 128 bits int + base62: "aa", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + uri: "cleary invalid uri", + // too high of a value, this would need a 132 bits int + base62: "ZZZZZZZZZZZZZZZZZZZZZZ", + }, + ]; + + struct ItemTypeCase { + uri: SpotifyUri, + expected_type: &'static str, + } + + static ITEM_TYPES: [ItemTypeCase; 6] = [ + ItemTypeCase { + uri: SpotifyUri::Album { + id: SpotifyId { id: 0 }, + }, + expected_type: "album", + }, + ItemTypeCase { + uri: SpotifyUri::Artist { + id: SpotifyId { id: 0 }, + }, + expected_type: "artist", + }, + ItemTypeCase { + uri: SpotifyUri::Episode { + id: SpotifyId { id: 0 }, + }, + expected_type: "episode", + }, + ItemTypeCase { + uri: SpotifyUri::Playlist { + user: None, + id: SpotifyId { id: 0 }, + }, + expected_type: "playlist", + }, + ItemTypeCase { + uri: SpotifyUri::Show { + id: SpotifyId { id: 0 }, + }, + expected_type: "show", + }, + ItemTypeCase { + uri: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + expected_type: "track", + }, + ]; + + #[test] + fn to_id() { + for c in &CONV_VALID { + assert_eq!(c.parsed.to_id().unwrap(), c.base62); + } + } + + #[test] + fn item_type() { + for i in &ITEM_TYPES { + assert_eq!(i.uri.item_type(), i.expected_type); + } + + // These need to use methods that can't be used in the static context like to_owned() and + // into(). + + let local_file = SpotifyUri::Local { + artist: "".to_owned(), + album_title: "".to_owned(), + track_title: "".to_owned(), + duration: Default::default(), + }; + + assert_eq!(local_file.item_type(), "local"); + + let unknown = SpotifyUri::Unknown { + kind: "not used".into(), + id: "".to_owned(), + }; + + assert_eq!(unknown.item_type(), "unknown"); + } + + #[test] + fn from_uri() { + for c in &CONV_VALID { + let actual = SpotifyUri::from_uri(c.uri).unwrap(); + + assert_eq!(actual, c.parsed); + } + + for c in &CONV_INVALID { + assert!(SpotifyUri::from_uri(c.uri).is_err()); + } + } + + #[test] + fn from_invalid_type_uri() { + let actual = + SpotifyUri::from_uri("spotify:arbitrarywhatever:5sWHDYs0csV6RS48xBl0tH").unwrap(); + + assert_eq!( + actual, + SpotifyUri::Unknown { + kind: "arbitrarywhatever".into(), + id: "5sWHDYs0csV6RS48xBl0tH".to_owned() + } + ) + } + + #[test] + fn from_local_uri() { + let actual = SpotifyUri::from_uri("spotify:local:xyz:123").unwrap(); + + assert_eq!( + actual, + SpotifyUri::Local { + artist: "unimplemented".to_owned(), + album_title: "unimplemented".to_owned(), + track_title: "unimplemented".to_owned(), + duration: Default::default(), + } + ); + } + + #[test] + fn from_named_uri() { + let actual = + SpotifyUri::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI").unwrap(); + + let SpotifyUri::Playlist { ref user, id } = actual else { + panic!("wrong id type"); + }; + + assert_eq!(*user, Some("spotify".to_owned())); + assert_eq!( + id, + SpotifyId { + id: 136159921382084734723401526672209703396 + }, + ); + } + + #[test] + fn to_uri() { + for c in &CONV_VALID { + assert_eq!(c.parsed.to_uri().unwrap(), c.uri); + } + } + + #[test] + fn to_named_uri() { + let string = "spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI"; + + let actual = + SpotifyUri::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI").unwrap(); + + assert_eq!(actual.to_uri().unwrap(), string); + } +} diff --git a/core/src/token.rs b/core/src/token.rs new file mode 100644 index 00000000..7e604797 --- /dev/null +++ b/core/src/token.rs @@ -0,0 +1,143 @@ +// Ported from librespot-java. Relicensed under MIT with permission. + +// Known scopes: +// ugc-image-upload, playlist-read-collaborative, playlist-modify-private, +// playlist-modify-public, playlist-read-private, user-read-playback-position, +// user-read-recently-played, user-top-read, user-modify-playback-state, +// user-read-currently-playing, user-read-playback-state, user-read-private, user-read-email, +// user-library-modify, user-library-read, user-follow-modify, user-follow-read, streaming, +// app-remote-control + +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use thiserror::Error; + +use crate::Error; + +component! { + TokenProvider : TokenProviderInner { + tokens: Vec = vec![], + } +} + +#[derive(Debug, Error)] +pub enum TokenError { + #[error("no tokens available")] + Empty, +} + +impl From for Error { + fn from(err: TokenError) -> Self { + Error::unavailable(err) + } +} + +#[derive(Clone, Debug)] +pub struct Token { + pub access_token: String, + pub expires_in: Duration, + pub token_type: String, + pub scopes: Vec, + pub timestamp: Instant, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenData { + access_token: String, + expires_in: u64, + token_type: String, + scope: Vec, +} + +impl TokenProvider { + fn find_token(&self, scopes: Vec<&str>) -> Option { + self.lock(|inner| { + (0..inner.tokens.len()).find(|&i| inner.tokens[i].in_scopes(scopes.clone())) + }) + } + + // Not all combinations of scopes and client ID are allowed. + // Depending on the client ID currently used, the function may return an error for specific scopes. + // In this case get_token_with_client_id() can be used, where an appropriate client ID can be provided. + // scopes must be comma-separated + pub async fn get_token(&self, scopes: &str) -> Result { + let client_id = self.session().client_id(); + self.get_token_with_client_id(scopes, &client_id).await + } + + pub async fn get_token_with_client_id( + &self, + scopes: &str, + client_id: &str, + ) -> Result { + if client_id.is_empty() { + return Err(Error::invalid_argument("Client ID cannot be empty")); + } + + if let Some(index) = self.find_token(scopes.split(',').collect()) { + let cached_token = self.lock(|inner| inner.tokens[index].clone()); + if cached_token.is_expired() { + self.lock(|inner| inner.tokens.remove(index)); + } else { + return Ok(cached_token); + } + } + + trace!( + "Requested token in scopes {scopes:?} unavailable or expired, requesting new token." + ); + + let query_uri = format!( + "hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}", + scopes, + client_id, + self.session().device_id(), + ); + let request = self.session().mercury().get(query_uri)?; + let response = request.await?; + let data = response.payload.first().ok_or(TokenError::Empty)?.to_vec(); + let token = Token::from_json(String::from_utf8(data)?)?; + trace!("Got token: {token:#?}"); + self.lock(|inner| inner.tokens.push(token.clone())); + Ok(token) + } +} + +impl Token { + const EXPIRY_THRESHOLD: Duration = Duration::from_secs(10); + + pub fn from_json(body: String) -> Result { + let data: TokenData = serde_json::from_slice(body.as_ref())?; + Ok(Self { + access_token: data.access_token, + expires_in: Duration::from_secs(data.expires_in), + token_type: data.token_type, + scopes: data.scope, + timestamp: Instant::now(), + }) + } + + pub fn is_expired(&self) -> bool { + self.timestamp + (self.expires_in.saturating_sub(Self::EXPIRY_THRESHOLD)) < Instant::now() + } + + pub fn in_scope(&self, scope: &str) -> bool { + for s in &self.scopes { + if *s == scope { + return true; + } + } + false + } + + pub fn in_scopes(&self, scopes: Vec<&str>) -> bool { + for s in scopes { + if !self.in_scope(s) { + return false; + } + } + true + } +} diff --git a/core/src/util.rs b/core/src/util.rs index df9ea714..04a33668 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -1,4 +1,102 @@ -use std::mem; +use crate::Error; +use byteorder::{BigEndian, ByteOrder}; +use futures_core::ready; +use futures_util::{FutureExt, Sink, SinkExt, future}; +use hmac::digest::Digest; +use sha1::Sha1; +use std::time::{Duration, Instant}; +use std::{ + future::Future, + mem, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::{task::JoinHandle, time::timeout}; + +/// Returns a future that will flush the sink, even if flushing is temporarily completed. +/// Finishes only if the sink throws an error. +pub(crate) fn keep_flushing<'a, T, S: Sink + Unpin + 'a>( + mut s: S, +) -> impl Future + 'a { + future::poll_fn(move |cx| match s.poll_flush_unpin(cx) { + Poll::Ready(Err(e)) => Poll::Ready(e), + _ => Poll::Pending, + }) +} + +pub struct CancelOnDrop(pub JoinHandle); + +impl Future for CancelOnDrop { + type Output = as Future>::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.0.poll_unpin(cx) + } +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +pub struct TimeoutOnDrop { + handle: Option>, + timeout: tokio::time::Duration, +} + +impl TimeoutOnDrop { + pub fn new(handle: JoinHandle, timeout: tokio::time::Duration) -> Self { + Self { + handle: Some(handle), + timeout, + } + } + + pub fn take(&mut self) -> Option> { + self.handle.take() + } +} + +impl Future for TimeoutOnDrop { + type Output = as Future>::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let r = ready!( + self.handle + .as_mut() + .expect("Polled after ready") + .poll_unpin(cx) + ); + self.handle = None; + Poll::Ready(r) + } +} + +impl Drop for TimeoutOnDrop { + fn drop(&mut self) { + let mut handle = if let Some(handle) = self.handle.take() { + handle + } else { + return; + }; + + if (&mut handle).now_or_never().is_some() { + // Already finished + return; + } + + match tokio::runtime::Handle::try_current() { + Ok(h) => { + h.spawn(timeout(self.timeout, CancelOnDrop(handle))); + } + Err(_) => { + // Not in tokio context, can't spawn + handle.abort(); + } + } + } +} pub trait Seq { fn next(&self) -> Self; @@ -27,3 +125,51 @@ impl SeqGenerator { mem::replace(&mut self.0, value) } } + +pub fn solve_hash_cash( + ctx: &[u8], + prefix: &[u8], + length: i32, + dst: &mut [u8], +) -> Result { + // after a certain number of seconds, the challenge expires + const TIMEOUT: u64 = 5; // seconds + let now = Instant::now(); + + let md = Sha1::digest(ctx); + + let mut counter: i64 = 0; + let target: i64 = BigEndian::read_i64(&md[12..20]); + + let suffix = loop { + if now.elapsed().as_secs() >= TIMEOUT { + return Err(Error::deadline_exceeded(format!( + "{TIMEOUT} seconds expired" + ))); + } + + let suffix = [(target + counter).to_be_bytes(), counter.to_be_bytes()].concat(); + + let mut hasher = Sha1::new(); + hasher.update(prefix); + hasher.update(&suffix); + let md = hasher.finalize(); + + if BigEndian::read_i64(&md[12..20]).trailing_zeros() >= (length as u32) { + break suffix; + } + + counter += 1; + }; + + dst.copy_from_slice(&suffix); + + Ok(now.elapsed()) +} + +pub fn get_next_query_separator(url: &str) -> &'static str { + match url.find('?') { + Some(_) => "&", + None => "?", + } +} diff --git a/core/src/version.rs b/core/src/version.rs index ef553463..0e48a47a 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -1,17 +1,53 @@ -/// Version string of the form "librespot-" -pub const VERSION_STRING: &str = concat!("librespot-", env!("VERGEN_SHA_SHORT")); +/// Version string of the form "librespot-\" +pub const VERSION_STRING: &str = concat!("librespot-", env!("VERGEN_GIT_SHA")); /// Generate a timestamp string representing the build date (UTC). pub const BUILD_DATE: &str = env!("VERGEN_BUILD_DATE"); /// Short sha of the latest git commit. -pub const SHA_SHORT: &str = env!("VERGEN_SHA_SHORT"); +pub const SHA_SHORT: &str = env!("VERGEN_GIT_SHA"); /// Date of the latest git commit. -pub const COMMIT_DATE: &str = env!("VERGEN_COMMIT_DATE"); +pub const COMMIT_DATE: &str = env!("VERGEN_GIT_COMMIT_DATE"); /// Librespot crate version. pub const SEMVER: &str = env!("CARGO_PKG_VERSION"); /// A random build id. pub const BUILD_ID: &str = env!("LIBRESPOT_BUILD_ID"); + +/// The protocol version of the Spotify desktop client. +pub const SPOTIFY_VERSION: u64 = 124200290; + +/// The semantic version of the Spotify desktop client. +pub const SPOTIFY_SEMANTIC_VERSION: &str = "1.2.52.442"; + +/// `property_set_id` related to desktop version 1.2.52.442 +pub const SPOTIFY_PROPERTY_SET_ID: &str = "b4c7e4b5835079ed94391b2e65fca0fdba65eb50"; + +/// The protocol version of the Spotify mobile app. +pub const SPOTIFY_MOBILE_VERSION: &str = "8.9.82.620"; + +/// `property_set_id` related to mobile version 8.9.82.620 +pub const SPOTIFY_MOBILE_PROPERTY_SET_ID: &str = + "5ec87c2cc32e7c509703582cfaaa3c7ad253129d5701127c1f5eab5c9531736c"; + +/// The general spirc version +pub const SPOTIFY_SPIRC_VERSION: &str = "3.2.6"; + +/// The user agent to fall back to, if one could not be determined dynamically. +pub const FALLBACK_USER_AGENT: &str = "Spotify/124200290 Linux/0 (librespot)"; + +pub fn spotify_version() -> String { + match crate::config::OS { + "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), + _ => SPOTIFY_VERSION.to_string(), + } +} + +pub fn spotify_semantic_version() -> String { + match crate::config::OS { + "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), + _ => SPOTIFY_SEMANTIC_VERSION.to_string(), + } +} diff --git a/core/tests/connect.rs b/core/tests/connect.rs index 8b95e437..91679f91 100644 --- a/core/tests/connect.rs +++ b/core/tests/connect.rs @@ -1,24 +1,19 @@ use std::time::Duration; -use librespot_core::authentication::Credentials; -use librespot_core::config::SessionConfig; -use librespot_core::session::Session; - use tokio::time::timeout; +use librespot_core::{authentication::Credentials, config::SessionConfig, session::Session}; + #[tokio::test] async fn test_connection() { timeout(Duration::from_secs(30), async { - let result = Session::connect( - SessionConfig::default(), - Credentials::with_password("test", "test"), - None, - ) - .await; + let result = Session::new(SessionConfig::default(), None) + .connect(Credentials::with_password("test", "test"), false) + .await; match result { Ok(_) => panic!("Authentication succeeded despite of bad credentials."), - Err(e) => assert_eq!(e.to_string(), "Login failed with reason: Bad credentials"), + Err(e) => assert!(!e.to_string().is_empty()), // there should be some error message } }) .await diff --git a/discovery/Cargo.toml b/discovery/Cargo.toml index 9b4d415e..2f86d5ac 100644 --- a/discovery/Cargo.toml +++ b/discovery/Cargo.toml @@ -1,40 +1,62 @@ [package] name = "librespot-discovery" -version = "0.3.1" +version.workspace = true +rust-version.workspace = true authors = ["Paul Lietar "] +license.workspace = true description = "The discovery logic for librespot" -license = "MIT" -repository = "https://github.com/librespot-org/librespot" -edition = "2018" +repository.workspace = true +edition.workspace = true + +[features] +# Refer to the workspace Cargo.toml for the list of features +default = ["with-libmdns", "native-tls"] + +# Discovery backends +with-avahi = ["dep:serde", "dep:zbus"] +with-dns-sd = ["dep:dns-sd"] +with-libmdns = ["dep:libmdns"] + +# TLS backend propagation +native-tls = ["librespot-core/native-tls"] +rustls-tls-native-roots = ["librespot-core/rustls-tls-native-roots"] +rustls-tls-webpki-roots = ["librespot-core/rustls-tls-webpki-roots"] [dependencies] -aes-ctr = "0.6" -base64 = "0.13" -cfg-if = "1.0" -form_urlencoded = "1.0" +librespot-core = { version = "0.7.1", path = "../core", default-features = false } + +aes = "0.8" +base64 = "0.22" +bytes = "1" +ctr = "0.9" +dns-sd = { version = "0.1", optional = true } +form_urlencoded = "1.2" futures-core = "0.3" -hmac = "0.11" -hyper = { version = "0.14", features = ["server", "http1", "tcp"] } -libmdns = "0.6" +futures-util = { version = "0.3", default-features = false, features = ["std"] } +hmac = "0.12" +http-body-util = "0.1" +hyper = { version = "1.6", features = ["http1"] } +hyper-util = { version = "0.1", features = [ + "server-auto", + "server-graceful", + "service", +] } +libmdns = { version = "0.10", optional = true } 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 } - -[dependencies.librespot-core] -path = "../core" -default_features = false -version = "0.3.1" +rand = { version = "0.9", default-features = false, features = ["thread_rng"] } +serde = { version = "1", default-features = false, features = [ + "derive", +], optional = true } +serde_repr = "0.1" +serde_json = "1.0" +sha1 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["sync", "rt"] } +zbus = { version = "5", default-features = false, features = [ + "tokio", +], optional = true } [dev-dependencies] futures = "0.3" hex = "0.4" -simple_logger = "1.11" -tokio = { version = "1.0", features = ["macros", "rt"] } - -[features] -with-dns-sd = ["dns-sd"] +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/discovery/examples/discovery.rs b/discovery/examples/discovery.rs index cd913fd2..6d41563e 100644 --- a/discovery/examples/discovery.rs +++ b/discovery/examples/discovery.rs @@ -1,25 +1,21 @@ use futures::StreamExt; +use librespot_core::SessionConfig; 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())); - let mut server = librespot_discovery::Discovery::builder(device_id) - .name(name) - .device_type(DeviceType::Computer) - .launch() - .unwrap(); + let mut server = + librespot_discovery::Discovery::builder(device_id, SessionConfig::default().client_id) + .name(name) + .device_type(DeviceType::Computer) + .launch() + .unwrap(); while let Some(x) = server.next().await { - println!("Received {:?}", x); + println!("Received {x:?}"); } } diff --git a/discovery/examples/discovery_group.rs b/discovery/examples/discovery_group.rs new file mode 100644 index 00000000..3022781a --- /dev/null +++ b/discovery/examples/discovery_group.rs @@ -0,0 +1,22 @@ +use futures::StreamExt; +use librespot_core::SessionConfig; +use librespot_discovery::DeviceType; +use sha1::{Digest, Sha1}; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let name = "Librespot Group"; + let device_id = hex::encode(Sha1::digest(name.as_bytes())); + + let mut server = + librespot_discovery::Discovery::builder(device_id, SessionConfig::default().client_id) + .name(name) + .device_type(DeviceType::Speaker) + .is_group(true) + .launch() + .unwrap(); + + while let Some(x) = server.next().await { + println!("Received {x:?}"); + } +} diff --git a/discovery/src/avahi.rs b/discovery/src/avahi.rs new file mode 100644 index 00000000..de720d65 --- /dev/null +++ b/discovery/src/avahi.rs @@ -0,0 +1,149 @@ +#![cfg(feature = "with-avahi")] + +#[allow(unused)] +pub use server::ServerProxy; + +#[allow(unused)] +pub use entry_group::{ + EntryGroupProxy, EntryGroupState, StateChangedStream as EntryGroupStateChangedStream, +}; + +mod server { + // This is not the full interface, just the methods we need! + // Avahi also implements a newer version of the interface ("org.freedesktop.Avahi.Server2"), but + // the additions are not relevant for us, and the older version is not intended to be deprecated. + // cf. the release notes for 0.8 at https://github.com/avahi/avahi/blob/master/docs/NEWS + #[zbus::proxy( + interface = "org.freedesktop.Avahi.Server", + default_service = "org.freedesktop.Avahi", + default_path = "/", + gen_blocking = false + )] + pub trait Server { + /// EntryGroupNew method + #[zbus(object = "super::entry_group::EntryGroup")] + fn entry_group_new(&self); + + /// GetState method + fn get_state(&self) -> zbus::Result; + + /// StateChanged signal + #[zbus(signal)] + fn state_changed(&self, state: i32, error: &str) -> zbus::Result<()>; + } +} + +mod entry_group { + use serde_repr::Deserialize_repr; + use zbus::zvariant; + + #[derive(Clone, Copy, Debug, Deserialize_repr)] + #[repr(i32)] + pub enum EntryGroupState { + // The group has not yet been committed, the user must still call avahi_entry_group_commit() + Uncommited = 0, + // The entries of the group are currently being registered + Registering = 1, + // The entries have successfully been established + Established = 2, + // A name collision for one of the entries in the group has been detected, the entries have been withdrawn + Collision = 3, + // Some kind of failure happened, the entries have been withdrawn + Failure = 4, + } + + impl zvariant::Type for EntryGroupState { + const SIGNATURE: &'static zvariant::Signature = &zvariant::Signature::I32; + } + + #[zbus::proxy( + interface = "org.freedesktop.Avahi.EntryGroup", + default_service = "org.freedesktop.Avahi", + gen_blocking = false + )] + pub trait EntryGroup { + /// AddAddress method + fn add_address( + &self, + interface: i32, + protocol: i32, + flags: u32, + name: &str, + address: &str, + ) -> zbus::Result<()>; + + /// AddRecord method + #[allow(clippy::too_many_arguments)] + fn add_record( + &self, + interface: i32, + protocol: i32, + flags: u32, + name: &str, + clazz: u16, + type_: u16, + ttl: u32, + rdata: &[u8], + ) -> zbus::Result<()>; + + /// AddService method + #[allow(clippy::too_many_arguments)] + fn add_service( + &self, + interface: i32, + protocol: i32, + flags: u32, + name: &str, + type_: &str, + domain: &str, + host: &str, + port: u16, + txt: &[&[u8]], + ) -> zbus::Result<()>; + + /// AddServiceSubtype method + #[allow(clippy::too_many_arguments)] + fn add_service_subtype( + &self, + interface: i32, + protocol: i32, + flags: u32, + name: &str, + type_: &str, + domain: &str, + subtype: &str, + ) -> zbus::Result<()>; + + /// Commit method + fn commit(&self) -> zbus::Result<()>; + + /// Free method + fn free(&self) -> zbus::Result<()>; + + /// GetState method + fn get_state(&self) -> zbus::Result; + + /// IsEmpty method + fn is_empty(&self) -> zbus::Result; + + /// Reset method + fn reset(&self) -> zbus::Result<()>; + + /// UpdateServiceTxt method + #[allow(clippy::too_many_arguments)] + fn update_service_txt( + &self, + interface: i32, + protocol: i32, + flags: u32, + name: &str, + type_: &str, + domain: &str, + txt: &[&[u8]], + ) -> zbus::Result<()>; + + /// StateChanged signal + #[zbus(signal)] + fn state_changed(&self, state: EntryGroupState, error: &str) -> zbus::Result<()>; + } +} diff --git a/discovery/src/lib.rs b/discovery/src/lib.rs index b1249a0d..e440c67f 100644 --- a/discovery/src/lib.rs +++ b/discovery/src/lib.rs @@ -7,28 +7,111 @@ //! 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 avahi; mod server; -use std::borrow::Cow; -use std::io; -use std::pin::Pin; -use std::task::{Context, Poll}; +use std::{ + borrow::Cow, + error::Error as StdError, + pin::Pin, + task::{Context, Poll}, +}; -use cfg_if::cfg_if; use futures_core::Stream; -use librespot_core as core; use thiserror::Error; +use tokio::sync::{mpsc, oneshot}; use self::server::DiscoveryServer; +pub use crate::core::Error; +use librespot_core as core; + /// Credentials to be used in [`librespot`](`librespot_core`). pub use crate::core::authentication::Credentials; /// Determining the icon in the list of available devices. pub use crate::core::config::DeviceType; +pub enum DiscoveryEvent { + Credentials(Credentials), + ServerError(DiscoveryError), + ZeroconfError(DiscoveryError), +} + +enum ZeroconfCmd { + Shutdown, +} + +pub struct DnsSdHandle { + task_handle: tokio::task::JoinHandle<()>, + shutdown_tx: oneshot::Sender, +} + +impl DnsSdHandle { + async fn shutdown(self) { + log::debug!("Shutting down zeroconf responder"); + let Self { + task_handle, + shutdown_tx, + } = self; + if shutdown_tx.send(ZeroconfCmd::Shutdown).is_err() { + log::warn!("Zeroconf responder unexpectedly disappeared"); + } else { + let _ = task_handle.await; + log::debug!("Zeroconf responder stopped"); + } + } +} + +pub type DnsSdServiceBuilder = fn( + Cow<'static, str>, + Vec, + u16, + mpsc::UnboundedSender, +) -> Result; + +// Default goes first: This matches the behaviour when feature flags were exlusive, i.e. when there +// was only `feature = "with-dns-sd"` or `not(feature = "with-dns-sd")` +pub const BACKENDS: &[( + &str, + // If None, the backend is known but wasn't compiled. + Option, +)] = &[ + #[cfg(feature = "with-avahi")] + ("avahi", Some(launch_avahi)), + #[cfg(not(feature = "with-avahi"))] + ("avahi", None), + #[cfg(feature = "with-dns-sd")] + ("dns-sd", Some(launch_dns_sd)), + #[cfg(not(feature = "with-dns-sd"))] + ("dns-sd", None), + #[cfg(feature = "with-libmdns")] + ("libmdns", Some(launch_libmdns)), + #[cfg(not(feature = "with-libmdns"))] + ("libmdns", None), +]; + +pub fn find(name: Option<&str>) -> Result { + if let Some(ref name) = name { + match BACKENDS.iter().find(|(id, _)| name == id) { + Some((_id, Some(launch_svc))) => Ok(*launch_svc), + Some((_id, None)) => Err(Error::unavailable(format!( + "librespot built without '{name}' support" + ))), + None => Err(Error::not_found(format!( + "unknown zeroconf backend '{name}'" + ))), + } + } else { + BACKENDS + .iter() + .find_map(|(_, launch_svc)| *launch_svc) + .ok_or(Error::unavailable( + "librespot built without zeroconf backends", + )) + } +} + /// Makes this device visible to Spotify clients in the local network. /// /// `Discovery` implements the [`Stream`] trait. Every time this device @@ -36,39 +119,328 @@ pub use crate::core::config::DeviceType; pub struct Discovery { server: DiscoveryServer, - #[cfg(not(feature = "with-dns-sd"))] - _svc: libmdns::Service, - #[cfg(feature = "with-dns-sd")] - _svc: dns_sd::DNSService, + /// An opaque handle to the DNS-SD service. Dropping this will unregister the service. + #[allow(unused)] + svc: DnsSdHandle, + + event_rx: mpsc::UnboundedReceiver, } /// A builder for [`Discovery`]. pub struct Builder { server_config: server::Config, port: u16, + zeroconf_ip: Vec, + zeroconf_backend: Option, } /// Errors that can occur while setting up a [`Discovery`] instance. #[derive(Debug, Error)] -pub enum Error { - /// Setting up service discovery via DNS-SD failed. +pub enum DiscoveryError { + #[error("Creating SHA1 block cipher failed")] + AesError(#[from] aes::cipher::InvalidLength), + #[error("Setting up dns-sd failed: {0}")] - DnsSdError(#[from] io::Error), - /// Setting up the http server failed. - #[error("Setting up the http server failed: {0}")] + DnsSdError(#[source] Box), + + #[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), +} + +#[cfg(feature = "with-avahi")] +impl From for DiscoveryError { + fn from(error: zbus::Error) -> Self { + Self::DnsSdError(Box::new(error)) + } +} + +impl From for Error { + fn from(err: DiscoveryError) -> Self { + match err { + DiscoveryError::AesError(_) => Error::unavailable(err), + DiscoveryError::DnsSdError(_) => Error::unavailable(err), + DiscoveryError::HmacError(_) => Error::invalid_argument(err), + DiscoveryError::HttpServerError(_) => Error::unavailable(err), + DiscoveryError::ParamsError(_) => Error::invalid_argument(err), + } + } +} + +#[allow(unused)] +const DNS_SD_SERVICE_NAME: &str = "_spotify-connect._tcp"; +#[allow(unused)] +const TXT_RECORD: [&str; 2] = ["VERSION=1.0", "CPath=/"]; + +#[cfg(feature = "with-avahi")] +async fn avahi_task( + name: Cow<'static, str>, + port: u16, + entry_group: &mut Option>, +) -> Result<(), DiscoveryError> { + use self::avahi::{EntryGroupState, ServerProxy}; + use futures_util::StreamExt; + + let conn = zbus::Connection::system().await?; + + // Wait for the daemon to show up. + // On error: Failed to listen for NameOwnerChanged signal => Fatal DBus issue + let bus = zbus::fdo::DBusProxy::new(&conn).await?; + let mut stream = bus + .receive_name_owner_changed_with_args(&[(0, "org.freedesktop.Avahi")]) + .await?; + + loop { + // Wait for Avahi daemon to be started + 'wait_avahi: { + while let Poll::Ready(Some(_)) = futures_util::poll!(stream.next()) { + // Drain queued name owner changes, since we're going to connect in a second + } + + // Ping after we connected to the signal since it might have shown up in the meantime + if let Ok(avahi_peer) = + zbus::fdo::PeerProxy::new(&conn, "org.freedesktop.Avahi", "/").await + { + if avahi_peer.ping().await.is_ok() { + log::debug!("Pinged Avahi: Available"); + break 'wait_avahi; + } + } + log::warn!( + "Failed to connect to Avahi, zeroconf discovery will not work until avahi-daemon is started. Check that it is installed and running" + ); + + // If it didn't, wait for the signal + match stream.next().await { + Some(_signal) => { + log::debug!("Avahi appeared"); + break 'wait_avahi; + } + // The stream ended, but this should never happen + None => { + return Err(zbus::Error::Failure("DBus disappeared".to_owned()).into()); + } + } + } + + // Connect to Avahi and publish the service + let avahi_server = ServerProxy::new(&conn).await?; + log::trace!("Connected to Avahi"); + + *entry_group = Some(avahi_server.entry_group_new().await?); + + let mut entry_group_state_stream = entry_group + .as_mut() + .unwrap() + .receive_state_changed() + .await?; + + entry_group + .as_mut() + .unwrap() + .add_service( + -1, // AVAHI_IF_UNSPEC + -1, // IPv4 and IPv6 + 0, // flags + &name, + DNS_SD_SERVICE_NAME, // type + "", // domain: let the server choose + "", // host: let the server choose + port, + &TXT_RECORD.map(|s| s.as_bytes()), + ) + .await?; + + entry_group.as_mut().unwrap().commit().await?; + log::debug!("Commited zeroconf service with name {}", &name); + + 'monitor_service: loop { + tokio::select! { + Some(state_changed) = entry_group_state_stream.next() => { + let (state, error) = match state_changed.args() { + Ok(sc) => (sc.state, sc.error), + Err(e) => { + log::warn!("Error on receiving EntryGroup state from Avahi: {}", e); + continue 'monitor_service; + } + }; + match state { + EntryGroupState::Uncommited | EntryGroupState::Registering => { + // Not yet registered, ignore. + } + EntryGroupState::Established => { + log::info!("Published zeroconf service"); + } + EntryGroupState::Collision => { + // This most likely means that librespot has unintentionally been started twice. + // Thus, don't retry with a new name, but abort. + // + // Note that the error would usually already be returned by + // entry_group.add_service above, so this state_changed handler + // won't be hit. + // + // EntryGroup has been withdrawn at this point already! + log::error!("zeroconf collision for name '{}'", &name); + return Err(zbus::Error::Failure(format!("zeroconf collision for name: {name}")).into()); + } + EntryGroupState::Failure => { + // TODO: Back off/treat as fatal? + // EntryGroup has been withdrawn at this point already! + // There seems to be no code in Avahi that actually sets this state. + log::error!("zeroconf failure: {}", error); + return Err(zbus::Error::Failure(format!("zeroconf failure: {error}")).into()); + } + } + } + _name_owner_change = stream.next() => { + break 'monitor_service; + } + } + } + + // Avahi disappeared (or the service was immediately taken over by a + // new daemon) => drop all handles, and reconnect + log::info!("Avahi disappeared, trying to reconnect"); + } +} + +#[cfg(feature = "with-avahi")] +fn launch_avahi( + name: Cow<'static, str>, + _zeroconf_ip: Vec, + port: u16, + status_tx: mpsc::UnboundedSender, +) -> Result { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let task_handle = tokio::spawn(async move { + let mut entry_group = None; + tokio::select! { + res = avahi_task(name, port, &mut entry_group) => { + if let Err(e) = res { + log::error!("Avahi error: {}", e); + let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e)); + } + }, + _ = shutdown_rx => { + if let Some(entry_group) = entry_group.as_mut() { + if let Err(e) = entry_group.free().await { + log::warn!("Failed to un-publish zeroconf service: {}", e); + } else { + log::debug!("Un-published zeroconf service"); + } + } + }, + } + }); + + Ok(DnsSdHandle { + task_handle, + shutdown_tx, + }) +} + +#[cfg(feature = "with-dns-sd")] +fn launch_dns_sd( + name: Cow<'static, str>, + _zeroconf_ip: Vec, + port: u16, + status_tx: mpsc::UnboundedSender, +) -> Result { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let task_handle = tokio::task::spawn_blocking(move || { + let inner = move || -> Result<(), DiscoveryError> { + let svc = dns_sd::DNSService::register( + Some(name.as_ref()), + DNS_SD_SERVICE_NAME, + None, + None, + port, + &TXT_RECORD, + ) + .map_err(|e| DiscoveryError::DnsSdError(Box::new(e)))?; + + let _ = shutdown_rx.blocking_recv(); + + std::mem::drop(svc); + + Ok(()) + }; + + if let Err(e) = inner() { + log::error!("dns_sd error: {}", e); + let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e)); + } + }); + + Ok(DnsSdHandle { + shutdown_tx, + task_handle, + }) +} + +#[cfg(feature = "with-libmdns")] +fn launch_libmdns( + name: Cow<'static, str>, + zeroconf_ip: Vec, + port: u16, + status_tx: mpsc::UnboundedSender, +) -> Result { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let task_handle = tokio::task::spawn_blocking(move || { + let inner = move || -> Result<(), DiscoveryError> { + let responder = if !zeroconf_ip.is_empty() { + libmdns::Responder::spawn_with_ip_list( + &tokio::runtime::Handle::current(), + zeroconf_ip, + ) + } else { + libmdns::Responder::spawn(&tokio::runtime::Handle::current()) + } + .map_err(|e| DiscoveryError::DnsSdError(Box::new(e)))?; + + let svc = responder.register(&DNS_SD_SERVICE_NAME, &name, port, &TXT_RECORD); + + let _ = shutdown_rx.blocking_recv(); + + std::mem::drop(svc); + + Ok(()) + }; + + if let Err(e) = inner() { + log::error!("libmdns error: {e}"); + let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e)); + } + }); + + Ok(DnsSdHandle { + shutdown_tx, + task_handle, + }) } impl Builder { - /// Starts a new builder using the provided device id. - pub fn new(device_id: impl Into) -> Self { + /// Starts a new builder using the provided device and client IDs. + pub fn new>(device_id: T, client_id: T) -> Self { Self { server_config: server::Config { name: "Librespot".into(), device_type: DeviceType::default(), + is_group: false, device_id: device_id.into(), + client_id: client_id.into(), }, port: 0, + zeroconf_ip: vec![], + zeroconf_backend: None, } } @@ -84,6 +456,24 @@ impl Builder { self } + /// Sets whether the device is a group. This affects the icon in Spotify clients. Default is `false`. + pub fn is_group(mut self, is_group: bool) -> Self { + self.server_config.is_group = is_group; + self + } + + /// Set the ip addresses on which it should listen to incoming connections. The default is all interfaces. + pub fn zeroconf_ip(mut self, zeroconf_ip: Vec) -> Self { + self.zeroconf_ip = zeroconf_ip; + self + } + + /// Set the zeroconf (MDNS and DNS-SD) implementation to use. + pub fn zeroconf_backend(mut self, zeroconf_backend: DnsSdServiceBuilder) -> Self { + self.zeroconf_backend = Some(zeroconf_backend); + self + } + /// Sets the port on which it should listen to incoming connections. /// The default value `0` means any port. pub fn port(mut self, port: u16) -> Self { @@ -96,48 +486,37 @@ impl Builder { /// # Errors /// If setting up the mdns service or creating the server fails, this function returns an error. pub fn launch(self) -> Result { + let name = self.server_config.name.clone(); + let zeroconf_ip = self.zeroconf_ip; + + let (event_tx, event_rx) = mpsc::unbounded_channel(); + 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, event_tx.clone())?; - let svc; - - cfg_if! { - if #[cfg(feature = "with-dns-sd")] { - svc = dns_sd::DNSService::register( - Some(name.as_ref()), - "_spotify-connect._tcp", - None, - None, - port, - &["VERSION=1.0", "CPath=/"], - ) - .unwrap(); - - } else { - let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?; - svc = responder.register( - "_spotify-connect._tcp".to_owned(), - name, - port, - &["VERSION=1.0", "CPath=/"], - ) - } - }; - - Ok(Discovery { server, _svc: svc }) + let launch_svc = self.zeroconf_backend.unwrap_or(find(None)?); + let svc = launch_svc(name, zeroconf_ip, port, event_tx)?; + Ok(Discovery { + server, + svc, + event_rx, + }) } } impl Discovery { /// Starts a [`Builder`] with the provided device id. - pub fn builder(device_id: impl Into) -> Builder { - Builder::new(device_id) + pub fn builder>(device_id: T, client_id: T) -> Builder { + Builder::new(device_id, client_id) } /// Create a new instance with the specified device id and default paramaters. - pub fn new(device_id: impl Into) -> Result { - Self::builder(device_id).launch() + pub fn new>(device_id: T, client_id: T) -> Result { + Self::builder(device_id, client_id).launch() + } + + pub async fn shutdown(self) { + tokio::join!(self.server.shutdown(), self.svc.shutdown(),); } } @@ -145,6 +524,15 @@ impl Stream for Discovery { type Item = Credentials; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.server).poll_next(cx) + match Pin::new(&mut self.event_rx).poll_recv(cx) { + // Yields credentials + Poll::Ready(Some(DiscoveryEvent::Credentials(creds))) => Poll::Ready(Some(creds)), + // Also terminate the stream on fatal server or MDNS/DNS-SD errors. + Poll::Ready(Some( + DiscoveryEvent::ServerError(_) | DiscoveryEvent::ZeroconfError(_), + )) => Poll::Ready(None), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } } } diff --git a/discovery/src/server.rs b/discovery/src/server.rs index 57f5bf46..a328d9d8 100644 --- a/discovery/src/server.rs +++ b/discovery/src/server.rs @@ -1,26 +1,33 @@ -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, + net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener}, + sync::{Arc, Mutex}, +}; -use aes_ctr::cipher::generic_array::GenericArray; -use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher}; -use aes_ctr::Aes128Ctr; -use futures_core::Stream; -use hmac::{Hmac, Mac, NewMac}; -use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Method, Request, Response, StatusCode}; -use log::{debug, warn}; +use aes::cipher::{KeyIvInit, StreamCipher}; +use base64::engine::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use bytes::Bytes; +use futures_util::{FutureExt, TryFutureExt}; +use hmac::{Hmac, Mac}; +use http_body_util::{BodyExt, Full}; +use hyper::{Method, Request, Response, StatusCode, body::Incoming}; + +use hyper_util::{rt::TokioIo, server::graceful::GracefulShutdown}; +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, DiscoveryEvent}; + +use crate::{ + core::config::DeviceType, + core::{Error, authentication::Credentials, diffie_hellman::DhLocalKeys}, +}; + +type Aes128Ctr = ctr::Ctr128BE; type Params<'a> = BTreeMap, Cow<'a, str>>; @@ -28,90 +35,142 @@ pub struct Config { pub name: Cow<'static, str>, pub device_type: DeviceType, pub device_id: String, + pub is_group: bool, + pub client_id: String, } struct RequestHandler { config: Config, + username: Mutex>, keys: DhLocalKeys, - tx: mpsc::UnboundedSender, + event_tx: mpsc::UnboundedSender, } impl RequestHandler { - fn new(config: Config) -> (Self, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - - let discovery = Self { + fn new(config: Config, event_tx: mpsc::UnboundedSender) -> Self { + Self { config, - keys: DhLocalKeys::random(&mut rand::thread_rng()), - tx, + username: Mutex::new(None), + keys: DhLocalKeys::random(&mut rand::rng()), + event_tx, + } + } + + fn active_user(&self) -> String { + if let Ok(maybe_username) = self.username.lock() { + maybe_username.clone().unwrap_or(String::new()) + } else { + warn!("username lock corrupted; read failed"); + String::from("!") + } + } + + fn handle_get_info(&self) -> Response> { + let public_key = BASE64.encode(self.keys.public_key()); + let device_type: &str = self.config.device_type.into(); + let active_user = self.active_user(); + + // options based on zeroconf guide, search for `groupStatus` on page + let group_status = if self.config.is_group { + "GROUP" + } else { + "NONE" }; - (discovery, rx) - } - - fn handle_get_info(&self) -> Response { - let public_key = base64::encode(&self.keys.public_key()); - let device_type: &str = self.config.device_type.into(); - + // See: https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf/ let body = json!({ "status": 101, - "statusString": "ERROR-OK", + "statusString": "OK", "spotifyError": 0, - "version": "2.7.1", + // departing from the Spotify documentation, Google Cast uses "5.0.0" + "version": "2.9.0", "deviceID": (self.config.device_id), - "remoteName": (self.config.name), - "activeUser": "", - "publicKey": (public_key), "deviceType": (device_type), - "libraryVersion": crate::core::version::SEMVER, - "accountReq": "PREMIUM", + "remoteName": (self.config.name), + // valid value seen in the wild: "empty" + "publicKey": (public_key), "brandDisplayName": "librespot", "modelDisplayName": "librespot", - "resolverVersion": "0", - "groupStatus": "NONE", - "voiceSupport": "NO", + "libraryVersion": crate::core::version::SEMVER, + "resolverVersion": "1", + // valid values are "GROUP" and "NONE" + "groupStatus": group_status, + // valid value documented & seen in the wild: "accesstoken" + // Using it will cause clients to fail to connect. + "tokenType": "default", + "clientID": (self.config.client_id), + "productID": 0, + // Other known scope: client-authorization-universal + // Comma-separated. + "scope": "streaming", + "availability": "", + "supported_drm_media_formats": [], + // TODO: bitmask but what are the flags? + "supported_capabilities": 1, + // undocumented but should still work + "accountReq": "PREMIUM", + "activeUser": active_user, + // others seen-in-the-wild: + // - "deviceAPI_isGroup": False }) .to_string(); - - Response::new(Body::from(body)) + let body = Bytes::from(body); + Response::new(Full::new(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]; - let encrypted = &encrypted_blob[16..encrypted_blob.len() - 20]; - let cksum = &encrypted_blob[encrypted_blob.len() - 20..encrypted_blob.len()]; + let encrypted_blob_len = encrypted_blob.len(); + if encrypted_blob_len < 16 { + return Err(DiscoveryError::HmacError(encrypted_blob.to_vec()).into()); + } - let base_key = Sha1::digest(&shared_key); + let iv = &encrypted_blob[0..16]; + let encrypted = &encrypted_blob[16..encrypted_blob_len - 20]; + let cksum = &encrypted_blob[encrypted_blob_len - 20..encrypted_blob_len]; + + let base_key = Sha1::digest(shared_key); let base_key = &base_key[..16]; let checksum_key = { - let mut h = - Hmac::::new_from_slice(base_key).expect("HMAC can take key of any size"); + let mut h = Hmac::::new_from_slice(base_key) + .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?; h.update(b"checksum"); h.finalize().into_bytes() }; let encryption_key = { - let mut h = - Hmac::::new_from_slice(base_key).expect("HMAC can take key of any size"); + let mut h = Hmac::::new_from_slice(base_key) + .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?; h.update(b"encryption"); h.finalize().into_bytes() }; - let mut h = - Hmac::::new_from_slice(&checksum_key).expect("HMAC can take key of any size"); + let mut h = Hmac::::new_from_slice(&checksum_key) + .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?; h.update(encrypted); - if h.verify(cksum).is_err() { - warn!("Login error for user {:?}: MAC mismatch", username); + if h.verify_slice(cksum).is_err() { + warn!("Login error for user {username:?}: MAC mismatch"); let result = json!({ "status": 102, "spotifyError": 1, @@ -119,40 +178,52 @@ impl RequestHandler { }); let body = result.to_string(); - return Response::new(Body::from(body)); + let body = Bytes::from(body); + return Ok(Response::new(Full::new(body))); } let decrypted = { let mut data = encrypted.to_vec(); - let mut cipher = Aes128Ctr::new( - GenericArray::from_slice(&encryption_key[0..16]), - GenericArray::from_slice(iv), - ); + let mut cipher = Aes128Ctr::new_from_slices(&encryption_key[0..16], iv) + .map_err(DiscoveryError::AesError)?; cipher.apply_keystream(&mut data); data }; - let credentials = Credentials::with_blob(username, &decrypted, &self.config.device_id); + let credentials = Credentials::with_blob(username, decrypted, &self.config.device_id)?; - self.tx.send(credentials).unwrap(); + { + let maybe_username = self.username.lock(); + self.event_tx + .send(DiscoveryEvent::Credentials(credentials))?; + if let Ok(mut username_field) = maybe_username { + *username_field = Some(String::from(username)); + } else { + warn!("username lock corrupted; write failed"); + } + } let result = json!({ "status": 101, "spotifyError": 0, - "statusString": "ERROR-OK" + "statusString": "OK", }); let body = result.to_string(); - Response::new(Body::from(body)) + let body = Bytes::from(body); + Ok(Response::new(Full::new(body))) } - fn not_found(&self) -> Response { + fn not_found(&self) -> Response> { let mut res = Response::default(); *res.status_mut() = StatusCode::NOT_FOUND; 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(); @@ -166,70 +237,122 @@ impl RequestHandler { debug!("{:?} {:?} {:?}", parts.method, parts.uri.path(), params); } - let body = hyper::body::to_bytes(body).await?; + let body = body.collect().await?.to_bytes(); params.extend(form_urlencoded::parse(&body)); 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(), - }) + })) } } +pub(crate) enum DiscoveryServerCmd { + Shutdown, +} + pub struct DiscoveryServer { - cred_rx: mpsc::UnboundedReceiver, - _close_tx: oneshot::Sender, + close_tx: oneshot::Sender, + task_handle: tokio::task::JoinHandle<()>, } impl DiscoveryServer { - pub fn new(config: Config, port: &mut u16) -> hyper::Result { - let (discovery, cred_rx) = RequestHandler::new(config); - let discovery = Arc::new(discovery); + pub fn new( + config: Config, + port: &mut u16, + event_tx: mpsc::UnboundedSender, + ) -> Result { + let discovery = RequestHandler::new(config, event_tx); + let address = if cfg!(windows) { + SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), *port) + } else { + // this creates a dual stack socket on non-windows systems + SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *port) + }; let (close_tx, close_rx) = oneshot::channel(); - let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), *port); - - let make_service = make_service_fn(move |_| { - let discovery = discovery.clone(); - async move { - Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().handle(request))) + let listener = match TcpListener::bind(address) { + Ok(listener) => listener, + Err(e) => { + warn!("Discovery server failed to start: {e}"); + return Err(e.into()); } - }); + }; - let server = hyper::Server::try_bind(&address)?.serve(make_service); + listener.set_nonblocking(true)?; + let listener = tokio::net::TcpListener::from_std(listener)?; - *port = server.local_addr().port(); - debug!("Zeroconf server listening on 0.0.0.0:{}", *port); - - tokio::spawn(async { - let result = server - .with_graceful_shutdown(async { - close_rx.await.unwrap_err(); - debug!("Shutting down discovery server"); - }) - .await; - - if let Err(e) = result { - warn!("Discovery server failed: {}", e); + match listener.local_addr() { + Ok(addr) => { + *port = addr.port(); + debug!("Zeroconf server listening on 0.0.0.0:{}", *port); } + Err(e) => { + warn!("Discovery server failed to start: {e}"); + return Err(e.into()); + } + } + + let task_handle = tokio::spawn(async move { + let discovery = Arc::new(discovery); + + let server = hyper::server::conn::http1::Builder::new(); + let graceful = GracefulShutdown::new(); + let mut close_rx = std::pin::pin!(close_rx); + loop { + tokio::select! { + Ok((stream, _)) = listener.accept() => { + let io = TokioIo::new(stream); + let discovery = discovery.clone(); + + let svc = hyper::service::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 + }); + + let conn = server.serve_connection(io, svc); + let fut = graceful.watch(conn); + tokio::spawn(async move { + // Errors are logged in the service_fn + let _ = fut.await; + }); + } + _ = &mut close_rx => { + break; + } + } + } + + graceful.shutdown().await; }); Ok(Self { - cred_rx, - _close_tx: close_tx, + close_tx, + task_handle, }) } -} -impl Stream for DiscoveryServer { - type Item = Credentials; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.cred_rx.poll_recv(cx) + pub async fn shutdown(self) { + let Self { + close_tx, + task_handle, + .. + } = self; + log::debug!("Shutting down discovery server"); + if close_tx.send(DiscoveryServerCmd::Shutdown).is_err() { + log::warn!("Discovery server unexpectedly disappeared"); + } else { + let _ = task_handle.await; + log::debug!("Discovery server stopped"); + } } } diff --git a/docs/connection.md b/docs/connection.md index e64fac7f..9f5fcc93 100644 --- a/docs/connection.md +++ b/docs/connection.md @@ -31,7 +31,7 @@ The client solves a challenge based on these two packets, and sends it back usin It also computes the shared keys used to encrypt the rest of the communication. ## Login challenge and cipher key computation. -The client starts by computing the DH shared secret using it's private key and the server's public key. +The client starts by computing the DH shared secret using its private key and the server's public key. HMAC-SHA1 is then used to compute the send and receive keys, as well as the login challenge. ``` diff --git a/docs/dealer.md b/docs/dealer.md new file mode 100644 index 00000000..24704214 --- /dev/null +++ b/docs/dealer.md @@ -0,0 +1,79 @@ +# Dealer + +When talking about the dealer, we are speaking about a websocket that represents the player as +spotify-connect device. The dealer is primarily used to receive updates and not to update the +state. + +## Messages and Requests + +There are two types of messages that are received via the dealer, Messages and Requests. +Messages are fire-and-forget and don't need a responses, while request expect a reply if the +request was processed successfully or failed. + +Because we publish our device with support for gzip, the message payload might be BASE64 encoded +and gzip compressed. If that is the case, the related headers send an entry for "Transfer-Encoding" +with the value of "gzip". + +### Messages + +Most messages librespot handles send bytes that can be easily converted into their respective +protobuf definition. Some outliers send json that can be usually mapped to an existing protobuf +definition. We use `protobuf-json-mapping` to a similar protobuf definition + +> Note: The json sometimes doesn't map exactly and can provide more fields than the protobuf +> definition expects. For messages, we usually ignore unknown fields. + +There are two types of messages, "informational" and "fire and forget commands". + +**Informational:** + +Informational messages send any changes done by the current user or of a client where the current user +is logged in. These messages contain for example changes to a own playlist, additions to the liked songs +or any update that a client sends. + +**Fire and Forget commands:** + +These are messages that send information that are requests to the current player. These are only send to +the active player. Volume update requests and the logout request are send as fire-forget-commands. + +### Requests + +The request payload is sent as json. There are almost usable protobuf definitions (see +files named like `es_(_request).proto`) for the commands, but they don't +align up with the expected values and are missing some major information we need for handling some +commands. Because of that we have our own model for the specific commands, see +[core/src/dealer/protocol/request.rs](../core/src/dealer/protocol/request.rs). + +All request modify the player-state. + +## Details + +This sections is for details and special hiccups in regards to handling that isn't completely intuitive. + +### UIDs + +A spotify item is identifiable by their uri. The `ContextTrack` and `ProvidedTrack` both have a `uid` +field. When we receive a context via the `context-resolver` it can return items (`ContextTrack`) that +may have their respective uid set. Some context like the collection and albums don't provide this +information. + +When a `uid` is missing, resorting the next tracks in an official client gets confused and sends +incorrect data via the `set_queue` request. To prevent this behavior we generate a uid for each +track that doesn't have an uid. Queue items become a "queue-uid" which is just a `q` with an +incrementing number. + +### Metadata + +For some client's (especially mobile) the metadata of a track is very important to display the +context correct. For example the "autoplay" metadata is relevant to display the correct context +info. + +Metadata can also be used to store data like the iteration when repeating a context. + +### Repeat + +The context repeating implementation is partly mimicked from the official client. The official +client allows skipping into negative iterations, this is currently not supported. + +Repeating is realized by filling the next tracks with multiple contexts separated by delimiters. +By that we only have to handle the delimiter when skipping to the next and previous track. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..9a5e794b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,36 @@ +# Examples + +This folder contains examples of how to use the `librespot` library for various purposes. + +## How to run the examples + +In general, to invoke an example, clone down the repo and use `cargo` as follows: + +``` +cargo run --example [filename] +``` + +in which `filename` is the file name of the example, for instance `get_token` or `play`. + +### Acquiring an access token + +Most examples require an access token as the first positional argument. **Note that an access token +gained by the client credentials flow will not work**. `librespot-oauth` provides a utility to +acquire an access token using an OAuth flow, which will be able to run the examples. To invoke this, +run: + +``` +cargo run --package librespot-oauth --example oauth_sync +``` + +A browser window will open and prompt you to authorize with Spotify. Once done, take the +`access_token` property from the dumped object response and proceed to use it in examples. You may +find it convenient to save it in a shell variable like `$ACCESS_TOKEN`. + +Once you have obtained the token you can proceed to run the example. Check each individual +file to see what arguments are expected. As a demonstration, here is how to invoke the `play` +example to play a song -- the second argument is the URI of the track to play. + +``` +cargo run --example play "$ACCESS_TOKEN" 2WUy2Uywcj5cP0IXQagO3z +``` \ No newline at end of file diff --git a/examples/get_token.rs b/examples/get_token.rs index 636155e0..77b6c8f7 100644 --- a/examples/get_token.rs +++ b/examples/get_token.rs @@ -1,33 +1,40 @@ use std::env; -use librespot::core::authentication::Credentials; -use librespot::core::config::SessionConfig; -use librespot::core::keymaster; -use librespot::core::session::Session; +use librespot::core::{authentication::Credentials, config::SessionConfig, session::Session}; const SCOPES: &str = "streaming,user-read-playback-state,user-modify-playback-state,user-read-currently-playing"; #[tokio::main] async fn main() { - let session_config = SessionConfig::default(); + let mut builder = env_logger::Builder::new(); + builder.parse_filters("librespot=trace"); + builder.init(); + + let mut session_config = SessionConfig::default(); let args: Vec<_> = env::args().collect(); - if args.len() != 4 { - eprintln!("Usage: {} USERNAME PASSWORD CLIENT_ID", args[0]); + if args.len() == 3 { + // Only special client IDs have sufficient privileges e.g. Spotify's. + session_config.client_id = args[2].clone() + } else if args.len() != 2 { + eprintln!("Usage: {} ACCESS_TOKEN [CLIENT_ID]", args[0]); return; } + let access_token = &args[1]; - println!("Connecting.."); - let credentials = Credentials::with_password(&args[1], &args[2]); - let session = Session::connect(session_config, credentials, None) - .await - .unwrap(); + // Now create a new session with that token. + let session = Session::new(session_config.clone(), None); + let credentials = Credentials::with_access_token(access_token); + println!("Connecting with token.."); + match session.connect(credentials, false).await { + Ok(()) => println!("Session username: {:#?}", session.username()), + Err(e) => { + println!("Error connecting: {e}"); + return; + } + }; - println!( - "Token: {:#?}", - keymaster::get_token(&session, &args[3], SCOPES) - .await - .unwrap() - ); + let token = session.token_provider().get_token(SCOPES).await.unwrap(); + println!("Got me a token: {token:#?}"); } diff --git a/examples/play.rs b/examples/play.rs index d6c7196d..32a86069 100644 --- a/examples/play.rs +++ b/examples/play.rs @@ -1,12 +1,17 @@ -use std::env; +use std::{env, process::exit}; -use librespot::core::authentication::Credentials; -use librespot::core::config::SessionConfig; -use librespot::core::session::Session; -use librespot::core::spotify_id::SpotifyId; -use librespot::playback::audio_backend; -use librespot::playback::config::{AudioFormat, PlayerConfig}; -use librespot::playback::player::Player; +use librespot::{ + core::{ + SpotifyUri, authentication::Credentials, config::SessionConfig, session::Session, + spotify_id::SpotifyId, + }, + playback::{ + audio_backend, + config::{AudioFormat, PlayerConfig}, + mixer::NoOpVolume, + player::Player, + }, +}; #[tokio::main] async fn main() { @@ -15,22 +20,26 @@ async fn main() { let audio_format = AudioFormat::default(); let args: Vec<_> = env::args().collect(); - if args.len() != 4 { - eprintln!("Usage: {} USERNAME PASSWORD TRACK", args[0]); + if args.len() != 3 { + eprintln!("Usage: {} ACCESS_TOKEN TRACK", args[0]); return; } - let credentials = Credentials::with_password(&args[1], &args[2]); + let credentials = Credentials::with_access_token(&args[1]); - let track = SpotifyId::from_base62(&args[3]).unwrap(); + let track = SpotifyUri::Track { + id: SpotifyId::from_base62(&args[2]).unwrap(), + }; let backend = audio_backend::find(None).unwrap(); - println!("Connecting .."); - let session = Session::connect(session_config, credentials, None) - .await - .unwrap(); + println!("Connecting..."); + let session = Session::new(session_config, None); + if let Err(e) = session.connect(credentials, false).await { + println!("Error connecting: {e}"); + exit(1); + } - let (mut player, _) = Player::new(player_config, session, None, move || { + let player = Player::new(player_config, session, Box::new(NoOpVolume), move || { backend(None, audio_format) }); diff --git a/examples/play_connect.rs b/examples/play_connect.rs new file mode 100644 index 00000000..1be6345b --- /dev/null +++ b/examples/play_connect.rs @@ -0,0 +1,77 @@ +use librespot::{ + connect::{ConnectConfig, LoadRequest, LoadRequestOptions, Spirc}, + core::{ + Error, authentication::Credentials, cache::Cache, config::SessionConfig, session::Session, + }, + playback::mixer::MixerConfig, + playback::{ + audio_backend, + config::{AudioFormat, PlayerConfig}, + mixer, + player::Player, + }, +}; + +use log::LevelFilter; + +const CACHE: &str = ".cache"; +const CACHE_FILES: &str = ".cache/files"; + +#[tokio::main] +async fn main() -> Result<(), Error> { + env_logger::builder() + .filter_module("librespot", LevelFilter::Debug) + .init(); + + let session_config = SessionConfig::default(); + let player_config = PlayerConfig::default(); + let audio_format = AudioFormat::default(); + let connect_config = ConnectConfig::default(); + let mixer_config = MixerConfig::default(); + let request_options = LoadRequestOptions::default(); + + let sink_builder = audio_backend::find(None).unwrap(); + let mixer_builder = mixer::find(None).unwrap(); + + let cache = Cache::new(Some(CACHE), Some(CACHE), Some(CACHE_FILES), None)?; + let credentials = cache + .credentials() + .ok_or(Error::unavailable("credentials not cached")) + .or_else(|_| { + librespot_oauth::OAuthClientBuilder::new( + &session_config.client_id, + "http://127.0.0.1:8898/login", + vec!["streaming"], + ) + .open_in_browser() + .build()? + .get_access_token() + .map(|t| Credentials::with_access_token(t.access_token)) + })?; + + let session = Session::new(session_config, Some(cache)); + let mixer = mixer_builder(mixer_config)?; + + let player = Player::new( + player_config, + session.clone(), + mixer.get_soft_volume(), + move || sink_builder(None, audio_format), + ); + + let (spirc, spirc_task) = + Spirc::new(connect_config, session.clone(), credentials, player, mixer).await?; + + // these calls can be seen as "queued" + spirc.activate()?; + spirc.load(LoadRequest::from_context_uri( + format!("spotify:user:{}:collection", session.username()), + request_options, + ))?; + spirc.play()?; + + // starting the connect device and processing the previously "queued" calls + spirc_task.await; + + Ok(()) +} diff --git a/examples/playlist_tracks.rs b/examples/playlist_tracks.rs index 75c656bb..a1b5cad5 100644 --- a/examples/playlist_tracks.rs +++ b/examples/playlist_tracks.rs @@ -1,10 +1,12 @@ -use std::env; +use std::{env, process::exit}; -use librespot::core::authentication::Credentials; -use librespot::core::config::SessionConfig; -use librespot::core::session::Session; -use librespot::core::spotify_id::SpotifyId; -use librespot::metadata::{Metadata, Playlist, Track}; +use librespot::{ + core::{ + authentication::Credentials, config::SessionConfig, session::Session, + spotify_uri::SpotifyUri, + }, + metadata::{Metadata, Playlist, Track}, +}; #[tokio::main] async fn main() { @@ -12,25 +14,29 @@ async fn main() { let session_config = SessionConfig::default(); let args: Vec<_> = env::args().collect(); - if args.len() != 4 { - eprintln!("Usage: {} USERNAME PASSWORD PLAYLIST", args[0]); + if args.len() != 3 { + eprintln!("Usage: {} ACCESS_TOKEN PLAYLIST", args[0]); return; } - let credentials = Credentials::with_password(&args[1], &args[2]); + let credentials = Credentials::with_access_token(&args[1]); - let uri_split = args[3].split(':'); - let uri_parts: Vec<&str> = uri_split.collect(); - println!("{}, {}, {}", uri_parts[0], uri_parts[1], uri_parts[2]); + let plist_uri = SpotifyUri::from_uri(&args[2]).unwrap_or_else(|_| { + eprintln!( + "PLAYLIST should be a playlist URI such as: \ + \"spotify:playlist:37i9dQZF1DXec50AjHrNTq\"" + ); + exit(1); + }); - let plist_uri = SpotifyId::from_base62(uri_parts[2]).unwrap(); + let session = Session::new(session_config, None); + if let Err(e) = session.connect(credentials, false).await { + println!("Error connecting: {e}"); + exit(1); + } - let session = Session::connect(session_config, credentials, None) - .await - .unwrap(); - - let plist = Playlist::get(&session, plist_uri).await.unwrap(); - println!("{:?}", plist); - for track_id in plist.tracks { + let plist = Playlist::get(&session, &plist_uri).await.unwrap(); + println!("{plist:?}"); + 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 8eb7be8c..d2d434ee 100644 --- a/metadata/Cargo.toml +++ b/metadata/Cargo.toml @@ -1,21 +1,31 @@ [package] name = "librespot-metadata" -version = "0.3.1" +version.workspace = true +rust-version.workspace = true authors = ["Paul Lietar "] +license.workspace = true description = "The metadata logic for librespot" -license = "MIT" -repository = "https://github.com/librespot-org/librespot" -edition = "2018" +repository.workspace = true +edition.workspace = true + +[features] +# Refer to the workspace Cargo.toml for the list of features +default = ["native-tls"] + +# TLS backend propagation +native-tls = ["librespot-core/native-tls"] +rustls-tls-native-roots = ["librespot-core/rustls-tls-native-roots"] +rustls-tls-webpki-roots = ["librespot-core/rustls-tls-webpki-roots"] [dependencies] -async-trait = "0.1" -byteorder = "1.3" -protobuf = "2.14.0" -log = "0.4" +librespot-core = { version = "0.7.1", path = "../core", default-features = false } +librespot-protocol = { version = "0.7.1", path = "../protocol", default-features = false } -[dependencies.librespot-core] -path = "../core" -version = "0.3.1" -[dependencies.librespot-protocol] -path = "../protocol" -version = "0.3.1" +async-trait = "0.1" +bytes = "1" +log = "0.4" +protobuf = "3.7" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" +uuid = { version = "1", default-features = false } diff --git a/metadata/src/album.rs b/metadata/src/album.rs new file mode 100644 index 00000000..b1b26468 --- /dev/null +++ b/metadata/src/album.rs @@ -0,0 +1,131 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::{ + Metadata, + artist::Artists, + availability::Availabilities, + copyright::Copyrights, + external_id::ExternalIds, + image::Images, + request::RequestResult, + restriction::Restrictions, + sale_period::SalePeriods, + track::Tracks, + util::{impl_deref_wrapped, impl_try_from_repeated}, +}; + +use librespot_core::{Error, Session, SpotifyUri, date::Date}; + +use librespot_protocol as protocol; +use protocol::metadata::Disc as DiscMessage; +pub use protocol::metadata::album::Type as AlbumType; + +#[derive(Debug, Clone)] +pub struct Album { + pub id: SpotifyUri, + pub name: String, + pub artists: Artists, + pub album_type: AlbumType, + pub label: String, + pub date: Date, + pub popularity: i32, + 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, Default)] +pub struct Albums(pub Vec); + +impl_deref_wrapped!(Albums, Vec); + +#[derive(Debug, Clone)] +pub struct Disc { + pub number: i32, + pub name: String, + pub tracks: Tracks, +} + +#[derive(Debug, Clone, Default)] +pub struct Discs(pub Vec); + +impl_deref_wrapped!(Discs, Vec); + +impl Album { + pub fn tracks(&self) -> impl Iterator { + self.discs.iter().flat_map(|disc| disc.tracks.iter()) + } +} + +#[async_trait] +impl Metadata for Album { + type Message = protocol::metadata::Album; + + async fn request(session: &Session, album_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Album { id: album_id } = album_uri else { + return Err(Error::invalid_argument("album_uri")); + }; + + session.spclient().get_album_metadata(album_id).await + } + + fn parse(msg: &Self::Message, _: &SpotifyUri) -> 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.name().to_owned(), + artists: album.artist.as_slice().try_into()?, + album_type: album.type_(), + label: album.label().to_owned(), + date: album.date.get_or_default().try_into()?, + popularity: album.popularity(), + covers: album.cover_group.get_or_default().into(), + external_ids: album.external_id.as_slice().into(), + discs: album.disc.as_slice().try_into()?, + reviews: album.review.to_vec(), + copyrights: album.copyright.as_slice().into(), + restrictions: album.restriction.as_slice().into(), + related: album.related.as_slice().try_into()?, + sale_periods: album.sale_period.as_slice().try_into()?, + cover_group: album.cover_group.image.as_slice().into(), + original_title: album.original_title().to_owned(), + version_title: album.version_title().to_owned(), + type_str: album.type_str().to_owned(), + availability: album.availability.as_slice().try_into()?, + }) + } +} + +impl_try_from_repeated!(::Message, Albums); + +impl TryFrom<&DiscMessage> for Disc { + type Error = librespot_core::Error; + fn try_from(disc: &DiscMessage) -> Result { + Ok(Self { + number: disc.number(), + name: disc.name().to_owned(), + tracks: disc.track.as_slice().try_into()?, + }) + } +} + +impl_try_from_repeated!(DiscMessage, Discs); diff --git a/metadata/src/artist.rs b/metadata/src/artist.rs new file mode 100644 index 00000000..5f443719 --- /dev/null +++ b/metadata/src/artist.rs @@ -0,0 +1,308 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::{ + Metadata, + album::Albums, + availability::Availabilities, + external_id::ExternalIds, + image::Images, + request::RequestResult, + restriction::Restrictions, + sale_period::SalePeriods, + track::Tracks, + util::{impl_deref_wrapped, impl_from_repeated, impl_try_from_repeated}, +}; + +use librespot_core::{Error, Session, SpotifyUri}; + +use librespot_protocol as protocol; +pub use protocol::metadata::artist_with_role::ArtistRole; + +use protocol::metadata::ActivityPeriod as ActivityPeriodMessage; +use protocol::metadata::AlbumGroup as AlbumGroupMessage; +use protocol::metadata::ArtistWithRole as ArtistWithRoleMessage; +use protocol::metadata::Biography as BiographyMessage; +use protocol::metadata::TopTracks as TopTracksMessage; + +#[derive(Debug, Clone)] +pub struct Artist { + pub id: SpotifyUri, + pub name: String, + pub popularity: i32, + pub top_tracks: CountryTopTracks, + pub albums: AlbumGroups, + pub singles: AlbumGroups, + pub compilations: AlbumGroups, + pub appears_on_albums: AlbumGroups, + pub external_ids: ExternalIds, + pub portraits: Images, + pub biographies: Biographies, + pub activity_periods: ActivityPeriods, + pub restrictions: Restrictions, + pub related: Artists, + pub is_portrait_album_cover: bool, + pub portrait_group: Images, + pub sales_periods: SalePeriods, + pub availabilities: Availabilities, +} + +#[derive(Debug, Clone, Default)] +pub struct Artists(pub Vec); + +impl_deref_wrapped!(Artists, Vec); + +#[derive(Debug, Clone)] +pub struct ArtistWithRole { + pub id: SpotifyUri, + pub name: String, + pub role: ArtistRole, +} + +#[derive(Debug, Clone, Default)] +pub struct ArtistsWithRole(pub Vec); + +impl_deref_wrapped!(ArtistsWithRole, Vec); + +#[derive(Debug, Clone)] +pub struct TopTracks { + pub country: String, + pub tracks: Tracks, +} + +#[derive(Debug, Clone, Default)] +pub struct CountryTopTracks(pub Vec); + +impl_deref_wrapped!(CountryTopTracks, Vec); + +#[derive(Debug, Clone, Default)] +pub struct AlbumGroup(pub Albums); + +impl_deref_wrapped!(AlbumGroup, Albums); + +/// `AlbumGroups` contains collections of album variants (different releases of the same album). +/// Ignoring the wrapping types it is structured roughly like this: +/// ```text +/// AlbumGroups [ +/// [Album1], [Album2-relelease, Album2-older-release], [Album3] +/// ] +/// ``` +/// In most cases only the current variant of each album is needed. A list of every album in its +/// current release variant can be obtained by using [`AlbumGroups::current_releases`] +#[derive(Debug, Clone, Default)] +pub struct AlbumGroups(pub Vec); + +impl_deref_wrapped!(AlbumGroups, Vec); + +#[derive(Debug, Clone)] +pub struct Biography { + pub text: String, + pub portraits: Images, + pub portrait_group: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct Biographies(pub Vec); + +impl_deref_wrapped!(Biographies, Vec); + +#[derive(Debug, Clone)] +pub enum ActivityPeriod { + Timespan { + start_year: u16, + end_year: Option, + }, + Decade(u16), +} + +#[derive(Debug, Clone, Default)] +pub struct ActivityPeriods(pub Vec); + +impl_deref_wrapped!(ActivityPeriods, Vec); + +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 + } +} + +impl Artist { + /// Get the full list of albums, not containing duplicate variants of the same albums. + /// + /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`] + pub fn albums_current(&self) -> impl Iterator { + self.albums.current_releases() + } + + /// Get the full list of singles, not containing duplicate variants of the same singles. + /// + /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`] + pub fn singles_current(&self) -> impl Iterator { + self.singles.current_releases() + } + + /// Get the full list of compilations, not containing duplicate variants of the same + /// compilations. + /// + /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`] + pub fn compilations_current(&self) -> impl Iterator { + self.compilations.current_releases() + } + + /// Get the full list of albums, not containing duplicate variants of the same albums. + /// + /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`] + pub fn appears_on_albums_current(&self) -> impl Iterator { + self.appears_on_albums.current_releases() + } +} + +#[async_trait] +impl Metadata for Artist { + type Message = protocol::metadata::Artist; + + async fn request(session: &Session, artist_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Artist { id: artist_id } = artist_uri else { + return Err(Error::invalid_argument("artist_uri")); + }; + + session.spclient().get_artist_metadata(artist_id).await + } + + fn parse(msg: &Self::Message, _: &SpotifyUri) -> 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.name().to_owned(), + popularity: artist.popularity(), + top_tracks: artist.top_track.as_slice().try_into()?, + albums: artist.album_group.as_slice().try_into()?, + singles: artist.single_group.as_slice().try_into()?, + compilations: artist.compilation_group.as_slice().try_into()?, + appears_on_albums: artist.appears_on_group.as_slice().try_into()?, + external_ids: artist.external_id.as_slice().into(), + portraits: artist.portrait.as_slice().into(), + biographies: artist.biography.as_slice().into(), + activity_periods: artist.activity_period.as_slice().try_into()?, + restrictions: artist.restriction.as_slice().into(), + related: artist.related.as_slice().try_into()?, + is_portrait_album_cover: artist.is_portrait_album_cover(), + portrait_group: artist + .portrait_group + .get_or_default() + .image + .as_slice() + .into(), + sales_periods: artist.sale_period.as_slice().try_into()?, + availabilities: artist.availability.as_slice().try_into()?, + }) + } +} + +impl_try_from_repeated!(::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.artist_name().to_owned(), + role: artist_with_role.role(), + }) + } +} + +impl_try_from_repeated!(ArtistWithRoleMessage, ArtistsWithRole); + +impl TryFrom<&TopTracksMessage> for TopTracks { + type Error = librespot_core::Error; + fn try_from(top_tracks: &TopTracksMessage) -> Result { + Ok(Self { + country: top_tracks.country().to_owned(), + tracks: top_tracks.track.as_slice().try_into()?, + }) + } +} + +impl_try_from_repeated!(TopTracksMessage, CountryTopTracks); + +impl TryFrom<&AlbumGroupMessage> for AlbumGroup { + type Error = librespot_core::Error; + fn try_from(album_groups: &AlbumGroupMessage) -> Result { + Ok(Self(album_groups.album.as_slice().try_into()?)) + } +} + +impl AlbumGroups { + /// Get the contained albums. This will only use the latest release / variant of an album if + /// multiple variants are available. This should be used if multiple variants of the same album + /// are not explicitely desired. + pub fn current_releases(&self) -> impl Iterator { + self.iter().filter_map(|agrp| agrp.first()) + } +} + +impl_try_from_repeated!(AlbumGroupMessage, AlbumGroups); + +impl From<&BiographyMessage> for Biography { + fn from(biography: &BiographyMessage) -> Self { + let portrait_group = biography + .portrait_group + .iter() + .map(|it| it.image.as_slice().into()) + .collect(); + + Self { + text: biography.text().to_owned(), + portraits: biography.portrait.as_slice().into(), + portrait_group, + } + } +} + +impl_from_repeated!(BiographyMessage, Biographies); + +impl TryFrom<&ActivityPeriodMessage> for ActivityPeriod { + type Error = librespot_core::Error; + + fn try_from(period: &ActivityPeriodMessage) -> Result { + let activity_period = match ( + period.has_decade(), + period.has_start_year(), + period.has_end_year(), + ) { + // (decade, start_year, end_year) + (true, false, false) => Self::Decade(period.decade().try_into()?), + (false, true, closed_period) => Self::Timespan { + start_year: period.start_year().try_into()?, + end_year: closed_period + .then(|| period.end_year().try_into()) + .transpose()?, + }, + _ => { + return Err(librespot_core::Error::failed_precondition( + "ActivityPeriod is expected to be either a decade or timespan", + )); + } + }; + Ok(activity_period) + } +} + +impl_try_from_repeated!(ActivityPeriodMessage, ActivityPeriods); diff --git a/metadata/src/audio/file.rs b/metadata/src/audio/file.rs new file mode 100644 index 00000000..cf70a88d --- /dev/null +++ b/metadata/src/audio/file.rs @@ -0,0 +1,134 @@ +use std::{ + collections::HashMap, + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use librespot_core::FileId; + +use crate::util::impl_deref_wrapped; +use librespot_protocol as protocol; +use protocol::metadata::AudioFile as AudioFileMessage; + +use librespot_protocol::metadata::audio_file::Format; +use protobuf::Enum; + +#[allow(non_camel_case_types)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum AudioFileFormat { + OGG_VORBIS_96, // 0 + OGG_VORBIS_160, // 1 + OGG_VORBIS_320, // 2 + MP3_256, // 3 + MP3_320, // 4 + MP3_160, // 5 + MP3_96, // 6 + MP3_160_ENC, // 7 + AAC_24, // 8 + AAC_48, // 9 + FLAC_FLAC, // 16 + XHE_AAC_24, // 18 + XHE_AAC_16, // 19 + XHE_AAC_12, // 20 + FLAC_FLAC_24BIT, // 22 + // not defined in protobuf, but sometimes send + AAC_160, // 10 + AAC_320, // 11 + MP4_128, // 12 + OTHER5, // 13 +} + +impl TryFrom for AudioFileFormat { + type Error = i32; + + fn try_from(value: i32) -> Result { + Ok(match value { + 10 => AudioFileFormat::AAC_160, + 11 => AudioFileFormat::AAC_320, + 12 => AudioFileFormat::MP4_128, + 13 => AudioFileFormat::OTHER5, + _ => Format::from_i32(value).ok_or(value)?.into(), + }) + } +} + +impl From for AudioFileFormat { + fn from(value: Format) -> Self { + match value { + Format::OGG_VORBIS_96 => AudioFileFormat::OGG_VORBIS_96, + Format::OGG_VORBIS_160 => AudioFileFormat::OGG_VORBIS_160, + Format::OGG_VORBIS_320 => AudioFileFormat::OGG_VORBIS_320, + Format::MP3_256 => AudioFileFormat::MP3_256, + Format::MP3_320 => AudioFileFormat::MP3_320, + Format::MP3_160 => AudioFileFormat::MP3_160, + Format::MP3_96 => AudioFileFormat::MP3_96, + Format::MP3_160_ENC => AudioFileFormat::MP3_160_ENC, + Format::AAC_24 => AudioFileFormat::AAC_24, + Format::AAC_48 => AudioFileFormat::AAC_48, + Format::FLAC_FLAC => AudioFileFormat::FLAC_FLAC, + Format::XHE_AAC_24 => AudioFileFormat::XHE_AAC_24, + Format::XHE_AAC_16 => AudioFileFormat::XHE_AAC_16, + Format::XHE_AAC_12 => AudioFileFormat::XHE_AAC_12, + Format::FLAC_FLAC_24BIT => AudioFileFormat::FLAC_FLAC_24BIT, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct AudioFiles(pub HashMap); + +impl_deref_wrapped!(AudioFiles, HashMap); + +impl AudioFiles { + pub fn is_ogg_vorbis(format: AudioFileFormat) -> bool { + matches!( + format, + AudioFileFormat::OGG_VORBIS_320 + | AudioFileFormat::OGG_VORBIS_160 + | AudioFileFormat::OGG_VORBIS_96 + ) + } + + pub fn is_mp3(format: AudioFileFormat) -> bool { + matches!( + format, + AudioFileFormat::MP3_320 + | AudioFileFormat::MP3_256 + | AudioFileFormat::MP3_160 + | AudioFileFormat::MP3_96 + | AudioFileFormat::MP3_160_ENC + ) + } + + pub fn is_flac(format: AudioFileFormat) -> bool { + matches!(format, AudioFileFormat::FLAC_FLAC) + } +} + +impl From<&[AudioFileMessage]> for AudioFiles { + fn from(files: &[AudioFileMessage]) -> Self { + let audio_files: HashMap = files + .iter() + .filter_map(|file| { + let file_id = FileId::from(file.file_id()); + let format = file + .format + .ok_or(format!("Ignoring file <{file_id}> with unspecified format",)) + .and_then(|format| match format.enum_value() { + Ok(f) => Ok((f.into(), file_id)), + Err(unknown) => Err(format!( + "Ignoring file <{file_id}> with unknown format {unknown}", + )), + }); + + if let Err(ref why) = format { + trace!("{why}"); + } + + format.ok() + }) + .collect(); + + AudioFiles(audio_files) + } +} diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs new file mode 100644 index 00000000..3df63d9e --- /dev/null +++ b/metadata/src/audio/item.rs @@ -0,0 +1,273 @@ +use std::fmt::Debug; + +use crate::{ + Metadata, + artist::ArtistsWithRole, + availability::{AudioItemAvailability, Availabilities, UnavailabilityReason}, + episode::Episode, + error::MetadataError, + image::{ImageSize, Images}, + restriction::Restrictions, + track::{Track, Tracks}, +}; + +use super::file::AudioFiles; + +use librespot_core::{Error, Session, SpotifyUri, date::Date, session::UserData}; + +pub type AudioItemResult = Result; + +#[derive(Debug, Clone)] +pub struct CoverImage { + pub url: String, + pub size: ImageSize, + pub width: i32, + pub height: i32, +} + +#[derive(Debug, Clone)] +pub struct AudioItem { + pub track_id: SpotifyUri, + pub uri: String, + pub files: AudioFiles, + pub name: String, + pub covers: Vec, + pub language: Vec, + pub duration_ms: u32, + pub is_explicit: bool, + pub availability: AudioItemAvailability, + pub alternatives: Option, + pub unique_fields: UniqueFields, +} + +#[derive(Debug, Clone)] +pub enum UniqueFields { + Track { + artists: ArtistsWithRole, + album: String, + album_artists: Vec, + popularity: u8, + number: u32, + disc_number: u32, + }, + Episode { + description: String, + publish_time: Date, + show_name: String, + }, +} + +impl AudioItem { + pub async fn get_file(session: &Session, uri: SpotifyUri) -> AudioItemResult { + let image_url = session + .get_user_attribute("image-url") + .unwrap_or_else(|| String::from("https://i.scdn.co/image/{file_id}")); + + match uri { + SpotifyUri::Track { .. } => { + let track = Track::get(session, &uri).await?; + + if track.duration <= 0 { + return Err(Error::unavailable(MetadataError::InvalidDuration( + track.duration, + ))); + } + + if track.is_explicit && session.filter_explicit_content() { + return Err(Error::unavailable(MetadataError::ExplicitContentFiltered)); + } + + let uri_string = uri.to_uri()?; + let album = track.album.name; + + let album_artists = track + .album + .artists + .0 + .into_iter() + .map(|a| a.name) + .collect::>(); + + let covers = get_covers(track.album.covers, image_url); + + let alternatives = if track.alternatives.is_empty() { + None + } else { + Some(track.alternatives) + }; + + let availability = if Date::now_utc() < track.earliest_live_timestamp { + Err(UnavailabilityReason::Embargo) + } else { + available_for_user( + &session.user_data(), + &track.availability, + &track.restrictions, + ) + }; + + let popularity = track.popularity.clamp(0, 100) as u8; + let number = track.number.max(0) as u32; + let disc_number = track.disc_number.max(0) as u32; + + let unique_fields = UniqueFields::Track { + artists: track.artists_with_role, + album, + album_artists, + popularity, + number, + disc_number, + }; + + Ok(Self { + track_id: uri, + uri: uri_string, + files: track.files, + name: track.name, + covers, + language: track.language_of_performance, + duration_ms: track.duration as u32, + is_explicit: track.is_explicit, + availability, + alternatives, + unique_fields, + }) + } + SpotifyUri::Episode { .. } => { + let episode = Episode::get(session, &uri).await?; + + if episode.duration <= 0 { + return Err(Error::unavailable(MetadataError::InvalidDuration( + episode.duration, + ))); + } + + if episode.is_explicit && session.filter_explicit_content() { + return Err(Error::unavailable(MetadataError::ExplicitContentFiltered)); + } + + let uri_string = uri.to_uri()?; + + let covers = get_covers(episode.covers, image_url); + + let availability = available_for_user( + &session.user_data(), + &episode.availability, + &episode.restrictions, + ); + + let unique_fields = UniqueFields::Episode { + description: episode.description, + publish_time: episode.publish_time, + show_name: episode.show_name, + }; + + Ok(Self { + track_id: uri, + uri: uri_string, + files: episode.audio, + name: episode.name, + covers, + language: vec![episode.language], + duration_ms: episode.duration as u32, + is_explicit: episode.is_explicit, + availability, + alternatives: None, + unique_fields, + }) + } + _ => Err(Error::unavailable(MetadataError::NonPlayable)), + } + } +} + +fn get_covers(covers: Images, image_url: String) -> Vec { + let mut covers = covers; + + covers.sort_by(|a, b| b.width.cmp(&a.width)); + + covers + .iter() + .filter_map(|cover| { + let cover_id = cover.id.to_string(); + + if !cover_id.is_empty() { + let cover_image = CoverImage { + url: image_url.replace("{file_id}", &cover_id), + size: cover.size, + width: cover.width, + height: cover.height, + }; + + Some(cover_image) + } else { + None + } + }) + .collect() +} + +fn allowed_for_user(user_data: &UserData, restrictions: &Restrictions) -> AudioItemAvailability { + let country = &user_data.country; + let user_catalogue = match user_data.attributes.get("catalogue") { + Some(catalogue) => catalogue, + None => "premium", + }; + + for premium_restriction in restrictions.iter().filter(|restriction| { + restriction + .catalogue_strs + .iter() + .any(|restricted_catalogue| restricted_catalogue == user_catalogue) + }) { + if let Some(allowed_countries) = &premium_restriction.countries_allowed { + // A restriction will specify either a whitelast *or* a blacklist, + // but not both. So restrict availability if there is a whitelist + // and the country isn't on it. + if allowed_countries.iter().any(|allowed| country == allowed) { + return Ok(()); + } else { + return Err(UnavailabilityReason::NotWhitelisted); + } + } + + if let Some(forbidden_countries) = &premium_restriction.countries_forbidden { + if forbidden_countries + .iter() + .any(|forbidden| country == forbidden) + { + return Err(UnavailabilityReason::Blacklisted); + } else { + return Ok(()); + } + } + } + + Ok(()) // no restrictions in place +} + +fn available(availability: &Availabilities) -> AudioItemAvailability { + if availability.is_empty() { + // not all items have availability specified + return Ok(()); + } + + if !(availability + .iter() + .any(|availability| Date::now_utc() >= availability.start)) + { + return Err(UnavailabilityReason::Embargo); + } + + Ok(()) +} + +fn available_for_user( + user_data: &UserData, + availability: &Availabilities, + restrictions: &Restrictions, +) -> AudioItemAvailability { + available(availability)?; + 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..af9ccdc9 --- /dev/null +++ b/metadata/src/audio/mod.rs @@ -0,0 +1,5 @@ +pub mod file; +pub mod item; + +pub use file::{AudioFileFormat, AudioFiles}; +pub use item::{AudioItem, UniqueFields}; diff --git a/metadata/src/availability.rs b/metadata/src/availability.rs new file mode 100644 index 00000000..7ee84cf1 --- /dev/null +++ b/metadata/src/availability.rs @@ -0,0 +1,50 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use thiserror::Error; + +use crate::util::{impl_deref_wrapped, impl_try_from_repeated}; + +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, Default)] +pub struct Availabilities(pub Vec); + +impl_deref_wrapped!(Availabilities, Vec); + +#[derive(Debug, Copy, Clone, Error)] +pub enum UnavailabilityReason { + #[error("blacklist present and country on it")] + Blacklisted, + #[error("available date is in the future")] + Embargo, + #[error("required data was not present")] + NoData, + #[error("whitelist present and country not on it")] + NotWhitelisted, +} + +impl TryFrom<&AvailabilityMessage> for Availability { + type Error = librespot_core::Error; + fn try_from(availability: &AvailabilityMessage) -> Result { + Ok(Self { + catalogue_strs: availability.catalogue_str.to_vec(), + start: availability.start.get_or_default().try_into()?, + }) + } +} + +impl_try_from_repeated!(AvailabilityMessage, Availabilities); diff --git a/metadata/src/content_rating.rs b/metadata/src/content_rating.rs new file mode 100644 index 00000000..29693e43 --- /dev/null +++ b/metadata/src/content_rating.rs @@ -0,0 +1,31 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::util::{impl_deref_wrapped, impl_from_repeated}; + +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, Default)] +pub struct ContentRatings(pub Vec); + +impl_deref_wrapped!(ContentRatings, Vec); + +impl From<&ContentRatingMessage> for ContentRating { + fn from(content_rating: &ContentRatingMessage) -> Self { + Self { + country: content_rating.country().to_owned(), + tags: content_rating.tag.to_vec(), + } + } +} + +impl_from_repeated!(ContentRatingMessage, ContentRatings); diff --git a/metadata/src/copyright.rs b/metadata/src/copyright.rs new file mode 100644 index 00000000..203647a4 --- /dev/null +++ b/metadata/src/copyright.rs @@ -0,0 +1,32 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::util::{impl_deref_wrapped, impl_from_repeated}; + +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, Default)] +pub struct Copyrights(pub Vec); + +impl_deref_wrapped!(Copyrights, Vec); + +impl From<&CopyrightMessage> for Copyright { + fn from(copyright: &CopyrightMessage) -> Self { + Self { + copyright_type: copyright.type_(), + text: copyright.text().to_owned(), + } + } +} + +impl_from_repeated!(CopyrightMessage, Copyrights); diff --git a/metadata/src/cover.rs b/metadata/src/cover.rs deleted file mode 100644 index 408e658e..00000000 --- a/metadata/src/cover.rs +++ /dev/null @@ -1,19 +0,0 @@ -use byteorder::{BigEndian, WriteBytesExt}; -use std::io::Write; - -use librespot_core::channel::ChannelData; -use librespot_core::session::Session; -use librespot_core::spotify_id::FileId; - -pub fn get(session: &Session, file: FileId) -> ChannelData { - let (channel_id, channel) = session.channel().allocate(); - let (_headers, data) = channel.split(); - - let mut packet: Vec = Vec::new(); - packet.write_u16::(channel_id).unwrap(); - packet.write_u16::(0).unwrap(); - packet.write(&file.0).unwrap(); - session.send_packet(0x19, packet); - - data -} diff --git a/metadata/src/episode.rs b/metadata/src/episode.rs new file mode 100644 index 00000000..847e8941 --- /dev/null +++ b/metadata/src/episode.rs @@ -0,0 +1,105 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::{ + Metadata, + audio::file::AudioFiles, + availability::Availabilities, + content_rating::ContentRatings, + image::Images, + request::RequestResult, + restriction::Restrictions, + util::{impl_deref_wrapped, impl_try_from_repeated}, + video::VideoFiles, +}; + +use librespot_core::{Error, Session, SpotifyUri, date::Date}; + +use librespot_protocol as protocol; +pub use protocol::metadata::episode::EpisodeType; + +#[derive(Debug, Clone)] +pub struct Episode { + pub id: SpotifyUri, + 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_name: String, + 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, Default)] +pub struct Episodes(pub Vec); + +impl_deref_wrapped!(Episodes, Vec); + +#[async_trait] +impl Metadata for Episode { + type Message = protocol::metadata::Episode; + + async fn request(session: &Session, episode_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Episode { id: episode_id } = episode_uri else { + return Err(Error::invalid_argument("episode_uri")); + }; + + session.spclient().get_episode_metadata(episode_id).await + } + + fn parse(msg: &Self::Message, _: &SpotifyUri) -> 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.name().to_owned(), + duration: episode.duration().to_owned(), + audio: episode.audio.as_slice().into(), + description: episode.description().to_owned(), + number: episode.number(), + publish_time: episode.publish_time.get_or_default().try_into()?, + covers: episode.cover_image.image.as_slice().into(), + language: episode.language().to_owned(), + is_explicit: episode.explicit().to_owned(), + show_name: episode.show.name().to_owned(), + videos: episode.video.as_slice().into(), + video_previews: episode.video_preview.as_slice().into(), + audio_previews: episode.audio_preview.as_slice().into(), + restrictions: episode.restriction.as_slice().into(), + freeze_frames: episode.freeze_frame.image.as_slice().into(), + keywords: episode.keyword.to_vec(), + allow_background_playback: episode.allow_background_playback(), + availability: episode.availability.as_slice().try_into()?, + external_url: episode.external_url().to_owned(), + episode_type: episode.type_(), + has_music_and_talk: episode.music_and_talk(), + content_rating: episode.content_rating.as_slice().into(), + is_audiobook_chapter: episode.is_audiobook_chapter(), + }) + } +} + +impl_try_from_repeated!(::Message, Episodes); diff --git a/metadata/src/error.rs b/metadata/src/error.rs new file mode 100644 index 00000000..26f5ce0b --- /dev/null +++ b/metadata/src/error.rs @@ -0,0 +1,14 @@ +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, + #[error("audio item duration can not be: {0}")] + InvalidDuration(i32), + #[error("track is marked as explicit, which client setting forbids")] + ExplicitContentFiltered, +} diff --git a/metadata/src/external_id.rs b/metadata/src/external_id.rs new file mode 100644 index 00000000..b5f50993 --- /dev/null +++ b/metadata/src/external_id.rs @@ -0,0 +1,31 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::util::{impl_deref_wrapped, impl_from_repeated}; + +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, Default)] +pub struct ExternalIds(pub Vec); + +impl_deref_wrapped!(ExternalIds, Vec); + +impl From<&ExternalIdMessage> for ExternalId { + fn from(external_id: &ExternalIdMessage) -> Self { + Self { + external_type: external_id.type_().to_owned(), + id: external_id.id().to_owned(), + } + } +} + +impl_from_repeated!(ExternalIdMessage, ExternalIds); diff --git a/metadata/src/image.rs b/metadata/src/image.rs new file mode 100644 index 00000000..4d201218 --- /dev/null +++ b/metadata/src/image.rs @@ -0,0 +1,92 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::util::{impl_deref_wrapped, impl_from_repeated, impl_try_from_repeated}; + +use librespot_core::{FileId, SpotifyUri}; + +use librespot_protocol as protocol; +use protocol::metadata::Image as ImageMessage; +use protocol::metadata::ImageGroup; +pub use protocol::metadata::image::Size as ImageSize; +use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage; +use protocol::playlist4_external::PictureSize as PictureSizeMessage; + +#[derive(Debug, Clone)] +pub struct Image { + pub id: FileId, + pub size: ImageSize, + pub width: i32, + pub height: i32, +} + +#[derive(Debug, Clone, Default)] +pub struct Images(pub Vec); + +impl From<&ImageGroup> for Images { + fn from(image_group: &ImageGroup) -> Self { + Self(image_group.image.iter().map(|i| i.into()).collect()) + } +} + +impl_deref_wrapped!(Images, Vec); + +#[derive(Debug, Clone)] +pub struct PictureSize { + pub target_name: String, + pub url: String, +} + +#[derive(Debug, Clone, Default)] +pub struct PictureSizes(pub Vec); + +impl_deref_wrapped!(PictureSizes, Vec); + +#[derive(Debug, Clone)] +pub struct TranscodedPicture { + pub target_name: String, + pub uri: SpotifyUri, +} + +#[derive(Debug, Clone)] +pub struct TranscodedPictures(pub Vec); + +impl_deref_wrapped!(TranscodedPictures, Vec); + +impl From<&ImageMessage> for Image { + fn from(image: &ImageMessage) -> Self { + Self { + id: image.into(), + size: image.size(), + width: image.width(), + height: image.height(), + } + } +} + +impl_from_repeated!(ImageMessage, Images); + +impl From<&PictureSizeMessage> for PictureSize { + fn from(size: &PictureSizeMessage) -> Self { + Self { + target_name: size.target_name().to_owned(), + url: size.url().to_owned(), + } + } +} + +impl_from_repeated!(PictureSizeMessage, PictureSizes); + +impl TryFrom<&TranscodedPictureMessage> for TranscodedPicture { + type Error = librespot_core::Error; + fn try_from(picture: &TranscodedPictureMessage) -> Result { + Ok(Self { + target_name: picture.target_name().to_owned(), + uri: picture.try_into()?, + }) + } +} + +impl_try_from_repeated!(TranscodedPictureMessage, TranscodedPictures); diff --git a/metadata/src/lib.rs b/metadata/src/lib.rs index 2ed9273e..b097f98c 100644 --- a/metadata/src/lib.rs +++ b/metadata/src/lib.rs @@ -1,460 +1,58 @@ -#![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, SpotifyUri}; -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 lyrics; +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 lyrics::Lyrics; +pub use playlist::Playlist; +pub use show::Show; +pub use track::Track; #[async_trait] pub trait Metadata: Send + Sized + 'static { - type Message: protobuf::Message; + type Message: protobuf::Message + std::fmt::Debug; - fn request_url(id: SpotifyId) -> String; - fn parse(msg: &Self::Message, session: &Session) -> Self; + // Request a protobuf + async fn request(session: &Session, id: &SpotifyUri) -> 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: &SpotifyUri) -> 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() - .filter_map(|item| { - let uri_split = item.get_uri().split(':'); - let uri_parts: Vec<&str> = uri_split.collect(); - SpotifyId::from_base62(uri_parts[2]).ok() - }) - .collect::>(); - - if tracks.len() != msg.get_length() as usize { - warn!( - "Got {} tracks, but the playlist should contain {} tracks.", - tracks.len(), - msg.get_length() - ); - } - - 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_file() - .iter() - .filter(|file| file.has_file_id()) - .map(|file| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(file.get_file_id()); - (file.get_format(), FileId(dst)) - }) - .collect(); - - let covers = msg - .get_covers() - .get_image() - .iter() - .filter(|image| image.has_file_id()) - .map(|image| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(image.get_file_id()); - FileId(dst) - }) - .collect::>(); - - 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_covers() - .get_image() - .iter() - .filter(|image| image.has_file_id()) - .map(|image| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(image.get_file_id()); - FileId(dst) - }) - .collect::>(); - - 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, _: &SpotifyUri) -> Result; } diff --git a/metadata/src/lyrics.rs b/metadata/src/lyrics.rs new file mode 100644 index 00000000..4a3095e1 --- /dev/null +++ b/metadata/src/lyrics.rs @@ -0,0 +1,76 @@ +use bytes::Bytes; + +use librespot_core::{Error, FileId, Session, SpotifyId}; + +impl Lyrics { + pub async fn get(session: &Session, id: &SpotifyId) -> Result { + let spclient = session.spclient(); + let lyrics = spclient.get_lyrics(id).await?; + Self::try_from(&lyrics) + } + + pub async fn get_for_image( + session: &Session, + id: &SpotifyId, + image_id: &FileId, + ) -> Result { + let spclient = session.spclient(); + let lyrics = spclient.get_lyrics_for_image(id, image_id).await?; + Self::try_from(&lyrics) + } +} + +impl TryFrom<&Bytes> for Lyrics { + type Error = Error; + + fn try_from(lyrics: &Bytes) -> Result { + serde_json::from_slice(lyrics).map_err(|err| err.into()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Lyrics { + pub colors: Colors, + pub has_vocal_removal: bool, + pub lyrics: LyricsInner, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Colors { + pub background: i32, + pub highlight_text: i32, + pub text: i32, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LyricsInner { + // TODO: 'alternatives' field as an array but I don't know what it's meant for + pub is_dense_typeface: bool, + pub is_rtl_language: bool, + pub language: String, + pub lines: Vec, + pub provider: String, + pub provider_display_name: String, + pub provider_lyrics_id: String, + pub sync_lyrics_uri: String, + pub sync_type: SyncType, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SyncType { + Unsynced, + LineSynced, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Line { + pub start_time_ms: String, + pub end_time_ms: String, + pub words: String, + // TODO: 'syllables' array +} diff --git a/metadata/src/playlist/annotation.rs b/metadata/src/playlist/annotation.rs new file mode 100644 index 00000000..b11d34da --- /dev/null +++ b/metadata/src/playlist/annotation.rs @@ -0,0 +1,100 @@ +use std::fmt::Debug; + +use protobuf::Message; + +use crate::{ + Metadata, + image::TranscodedPictures, + request::{MercuryRequest, RequestResult}, +}; + +use librespot_core::{Error, Session, SpotifyId, SpotifyUri}; +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_uri: &SpotifyUri) -> RequestResult { + let current_user = session.username(); + + let SpotifyUri::Playlist { + id: playlist_id, .. + } = playlist_uri + else { + return Err(Error::invalid_argument("playlist_uri")); + }; + + Self::request_for_user(session, ¤t_user, playlist_id).await + } + + fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result { + Ok(Self { + description: msg.description().to_owned(), + picture: msg.picture().to_owned(), // TODO: is this a URL or Spotify URI? + transcoded_pictures: msg.transcoded_picture.as_slice().try_into()?, + has_abuse_reporting: msg.is_abuse_reporting_enabled(), + abuse_report_state: msg.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_uri: &SpotifyUri, + ) -> Result { + let SpotifyUri::Playlist { + id: playlist_id, .. + } = playlist_uri + else { + return Err(Error::invalid_argument("playlist_uri")); + }; + + let response = Self::request_for_user(session, username, playlist_id).await?; + let msg = ::Message::parse_from_bytes(&response)?; + Self::parse(&msg, playlist_uri) + } +} + +impl MercuryRequest for PlaylistAnnotation {} + +impl TryFrom<&::Message> for PlaylistAnnotation { + type Error = librespot_core::Error; + fn try_from( + annotation: &::Message, + ) -> Result { + Ok(Self { + description: annotation.description().to_owned(), + picture: annotation.picture().to_owned(), + transcoded_pictures: annotation.transcoded_picture.as_slice().try_into()?, + has_abuse_reporting: annotation.is_abuse_reporting_enabled(), + abuse_report_state: annotation.abuse_report_state(), + }) + } +} diff --git a/metadata/src/playlist/attribute.rs b/metadata/src/playlist/attribute.rs new file mode 100644 index 00000000..86a8f6c5 --- /dev/null +++ b/metadata/src/playlist/attribute.rs @@ -0,0 +1,190 @@ +use std::{ + collections::HashMap, + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::{ + image::PictureSizes, + util::{impl_deref_wrapped, impl_from_repeated_copy}, +}; + +use librespot_core::date::Date; + +use librespot_protocol as protocol; +use protocol::playlist4_external::FormatListAttribute as PlaylistFormatAttributeMessage; +pub use protocol::playlist4_external::ItemAttributeKind as PlaylistItemAttributeKind; +use protocol::playlist4_external::ItemAttributes as PlaylistItemAttributesMessage; +use protocol::playlist4_external::ItemAttributesPartialState as PlaylistPartialItemAttributesMessage; +pub use protocol::playlist4_external::ListAttributeKind as PlaylistAttributeKind; +use protocol::playlist4_external::ListAttributes as PlaylistAttributesMessage; +use protocol::playlist4_external::ListAttributesPartialState as PlaylistPartialAttributesMessage; +use protocol::playlist4_external::UpdateItemAttributes as PlaylistUpdateItemAttributesMessage; +use protocol::playlist4_external::UpdateListAttributes as PlaylistUpdateAttributesMessage; + +#[derive(Debug, Clone)] +pub struct PlaylistAttributes { + pub name: String, + pub description: String, + pub picture: Vec, + pub is_collaborative: bool, + pub pl3_version: String, + pub is_deleted_by_owner: bool, + pub client_id: String, + pub format: String, + pub format_attributes: PlaylistFormatAttribute, + pub picture_sizes: PictureSizes, +} + +#[derive(Debug, Clone, Default)] +pub struct PlaylistAttributeKinds(pub Vec); + +impl_deref_wrapped!(PlaylistAttributeKinds, Vec); + +impl_from_repeated_copy!(PlaylistAttributeKind, PlaylistAttributeKinds); + +#[derive(Debug, Clone, Default)] +pub struct PlaylistFormatAttribute(pub HashMap); + +impl_deref_wrapped!(PlaylistFormatAttribute, HashMap); + +#[derive(Debug, Clone)] +pub struct PlaylistItemAttributes { + pub added_by: String, + pub timestamp: Date, + pub seen_at: Date, + pub is_public: bool, + pub format_attributes: PlaylistFormatAttribute, + pub item_id: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct PlaylistItemAttributeKinds(pub Vec); + +impl_deref_wrapped!(PlaylistItemAttributeKinds, Vec); + +impl_from_repeated_copy!(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.name().to_owned(), + description: attributes.description().to_owned(), + picture: attributes.picture().to_owned(), + is_collaborative: attributes.collaborative(), + pl3_version: attributes.pl3_version().to_owned(), + is_deleted_by_owner: attributes.deleted_by_owner(), + client_id: attributes.client_id().to_owned(), + format: attributes.format().to_owned(), + format_attributes: attributes.format_attributes.as_slice().into(), + picture_sizes: attributes.picture_size.as_slice().into(), + }) + } +} + +impl From<&[PlaylistFormatAttributeMessage]> for PlaylistFormatAttribute { + fn from(attributes: &[PlaylistFormatAttributeMessage]) -> Self { + let format_attributes = attributes + .iter() + .map(|attribute| (attribute.key().to_owned(), attribute.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.added_by().to_owned(), + timestamp: Date::from_timestamp_ms(attributes.timestamp())?, + seen_at: Date::from_timestamp_ms(attributes.seen_at())?, + is_public: attributes.public(), + format_attributes: attributes.format_attributes.as_slice().into(), + item_id: attributes.item_id().to_owned(), + }) + } +} +impl TryFrom<&PlaylistPartialAttributesMessage> for PlaylistPartialAttributes { + type Error = librespot_core::Error; + fn try_from(attributes: &PlaylistPartialAttributesMessage) -> Result { + Ok(Self { + values: attributes.values.get_or_default().try_into()?, + no_value: attributes + .no_value + .iter() + .map(|v| v.enum_value_or_default()) + .collect::>() + .as_slice() + .into(), + }) + } +} + +impl TryFrom<&PlaylistPartialItemAttributesMessage> for PlaylistPartialItemAttributes { + type Error = librespot_core::Error; + fn try_from(attributes: &PlaylistPartialItemAttributesMessage) -> Result { + Ok(Self { + values: attributes.values.get_or_default().try_into()?, + no_value: attributes + .no_value + .iter() + .map(|v| v.enum_value_or_default()) + .collect::>() + .as_slice() + .into(), + }) + } +} + +impl TryFrom<&PlaylistUpdateAttributesMessage> for PlaylistUpdateAttributes { + type Error = librespot_core::Error; + fn try_from(update: &PlaylistUpdateAttributesMessage) -> Result { + Ok(Self { + new_attributes: update.new_attributes.get_or_default().try_into()?, + old_attributes: update.old_attributes.get_or_default().try_into()?, + }) + } +} + +impl TryFrom<&PlaylistUpdateItemAttributesMessage> for PlaylistUpdateItemAttributes { + type Error = librespot_core::Error; + fn try_from(update: &PlaylistUpdateItemAttributesMessage) -> Result { + Ok(Self { + index: update.index(), + new_attributes: update.new_attributes.get_or_default().try_into()?, + old_attributes: update.old_attributes.get_or_default().try_into()?, + }) + } +} diff --git a/metadata/src/playlist/diff.rs b/metadata/src/playlist/diff.rs new file mode 100644 index 00000000..d47578c0 --- /dev/null +++ b/metadata/src/playlist/diff.rs @@ -0,0 +1,36 @@ +use std::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 + .from_revision + .clone() + .unwrap_or_default() + .as_slice() + .try_into()?, + operations: diff.ops.as_slice().try_into()?, + to_revision: diff + .to_revision + .clone() + .unwrap_or_default() + .as_slice() + .try_into()?, + }) + } +} diff --git a/metadata/src/playlist/item.rs b/metadata/src/playlist/item.rs new file mode 100644 index 00000000..1746857b --- /dev/null +++ b/metadata/src/playlist/item.rs @@ -0,0 +1,94 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::util::{impl_deref_wrapped, impl_try_from_repeated}; + +use super::{ + attribute::{PlaylistAttributes, PlaylistItemAttributes}, + permission::Capabilities, +}; + +use librespot_core::{SpotifyUri, date::Date}; + +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: SpotifyUri, + pub attributes: PlaylistItemAttributes, +} + +#[derive(Debug, Clone, Default)] +pub struct PlaylistItems(pub Vec); + +impl_deref_wrapped!(PlaylistItems, Vec); + +#[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: SpotifyUri, + pub attributes: PlaylistAttributes, + pub length: i32, + pub timestamp: Date, + pub owner_username: String, + pub has_abuse_reporting: bool, + pub capabilities: Capabilities, +} + +#[derive(Debug, Clone, Default)] +pub struct PlaylistMetaItems(pub Vec); + +impl_deref_wrapped!(PlaylistMetaItems, Vec); + +impl TryFrom<&PlaylistItemMessage> for PlaylistItem { + type Error = librespot_core::Error; + fn try_from(item: &PlaylistItemMessage) -> Result { + Ok(Self { + id: item.try_into()?, + attributes: item.attributes.get_or_default().try_into()?, + }) + } +} + +impl_try_from_repeated!(PlaylistItemMessage, PlaylistItems); + +impl TryFrom<&PlaylistItemsMessage> for PlaylistItemList { + type Error = librespot_core::Error; + fn try_from(list_items: &PlaylistItemsMessage) -> Result { + Ok(Self { + position: list_items.pos(), + is_truncated: list_items.truncated(), + items: list_items.items.as_slice().try_into()?, + meta_items: list_items.meta_items.as_slice().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.attributes.get_or_default().try_into()?, + length: item.length(), + timestamp: Date::from_timestamp_ms(item.timestamp())?, + owner_username: item.owner_username().to_owned(), + has_abuse_reporting: item.abuse_reporting_enabled(), + capabilities: item.capabilities.get_or_default().into(), + }) + } +} + +impl_try_from_repeated!(PlaylistMetaItemMessage, PlaylistMetaItems); diff --git a/metadata/src/playlist/list.rs b/metadata/src/playlist/list.rs new file mode 100644 index 00000000..1052afd8 --- /dev/null +++ b/metadata/src/playlist/list.rs @@ -0,0 +1,194 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::{ + Metadata, + request::RequestResult, + util::{impl_deref_wrapped, impl_from_repeated_copy, impl_try_from_repeated}, +}; + +use super::{ + attribute::PlaylistAttributes, diff::PlaylistDiff, item::PlaylistItemList, + permission::Capabilities, +}; + +use librespot_core::{Error, Session, SpotifyUri, date::Date, spotify_id::SpotifyId}; +use librespot_protocol as protocol; +use protocol::playlist4_external::GeoblockBlockingType as Geoblock; + +#[derive(Debug, Clone, Default)] +pub struct Geoblocks(Vec); + +impl_deref_wrapped!(Geoblocks, Vec); + +#[derive(Debug, Clone)] +pub struct Playlist { + pub id: SpotifyUri, + pub revision: Vec, + pub length: i32, + pub attributes: PlaylistAttributes, + pub contents: PlaylistItemList, + pub diff: Option, + pub sync_result: Option, + pub resulting_revisions: Playlists, + pub has_multiple_heads: bool, + pub is_up_to_date: bool, + pub nonces: Vec, + pub timestamp: Date, + pub has_abuse_reporting: bool, + pub capabilities: Capabilities, + pub geoblocks: Geoblocks, +} + +#[derive(Debug, Clone, Default)] +pub struct Playlists(pub Vec); + +impl_deref_wrapped!(Playlists, Vec); + +#[derive(Debug, Clone)] +pub struct SelectedListContent { + pub revision: Vec, + pub length: i32, + pub attributes: PlaylistAttributes, + pub contents: PlaylistItemList, + pub diff: Option, + pub sync_result: Option, + pub resulting_revisions: Playlists, + pub has_multiple_heads: bool, + pub is_up_to_date: bool, + pub nonces: Vec, + pub timestamp: Date, + pub owner_username: String, + pub has_abuse_reporting: bool, + pub capabilities: Capabilities, + pub geoblocks: Geoblocks, +} + +impl Playlist { + pub fn tracks(&self) -> impl ExactSizeIterator { + let tracks = self.contents.items.iter().map(|item| &item.id); + + let length = tracks.len(); + let expected_length = self.length as usize; + if length != expected_length { + warn!("Got {length} tracks, but the list should contain {expected_length} tracks.",); + } + + tracks + } + + pub fn name(&self) -> &str { + &self.attributes.name + } +} + +#[async_trait] +impl Metadata for Playlist { + type Message = protocol::playlist4_external::SelectedListContent; + + async fn request(session: &Session, playlist_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Playlist { + id: playlist_id, .. + } = playlist_uri + else { + return Err(Error::invalid_argument("playlist_uri")); + }; + + session.spclient().get_playlist(playlist_id).await + } + + fn parse(msg: &Self::Message, uri: &SpotifyUri) -> Result { + let SpotifyUri::Playlist { + id: playlist_id, .. + } = uri + else { + return Err(Error::invalid_argument("playlist_uri")); + }; + + // the playlist proto doesn't contain the id so we decorate it + let playlist = SelectedListContent::try_from(msg)?; + + let new_uri = SpotifyUri::Playlist { + id: *playlist_id, + user: Some(playlist.owner_username), + }; + + Ok(Self { + id: new_uri, + 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 TryFrom<&::Message> for SelectedListContent { + type Error = librespot_core::Error; + fn try_from(playlist: &::Message) -> Result { + let timestamp = playlist.timestamp(); + let timestamp = if timestamp > 9295169800000 { + // timestamp is way out of range for milliseconds. Some seem to be in microseconds? + // Observed on playlists where: + // format: "artist-mix-reader" + // format_attributes { + // key: "mediaListConfig" + // value: "spotify:medialistconfig:artist-seed-mix:default_v18" + // } + warn!("timestamp is very large; assuming it's in microseconds"); + timestamp / 1000 + } else { + timestamp + }; + let timestamp = Date::from_timestamp_ms(timestamp)?; + + Ok(Self { + revision: playlist.revision().to_owned(), + length: playlist.length(), + attributes: playlist.attributes.get_or_default().try_into()?, + contents: playlist.contents.get_or_default().try_into()?, + diff: playlist.diff.as_ref().map(TryInto::try_into).transpose()?, + sync_result: playlist + .sync_result + .as_ref() + .map(TryInto::try_into) + .transpose()?, + resulting_revisions: Playlists( + playlist + .resulting_revisions + .iter() + .map(|p| p.try_into()) + .collect::, Error>>()?, + ), + has_multiple_heads: playlist.multiple_heads(), + is_up_to_date: playlist.up_to_date(), + nonces: playlist.nonces.clone(), + timestamp, + owner_username: playlist.owner_username().to_owned(), + has_abuse_reporting: playlist.abuse_reporting_enabled(), + capabilities: playlist.capabilities.get_or_default().into(), + geoblocks: Geoblocks( + playlist + .geoblock + .iter() + .map(|b| b.enum_value_or_default()) + .collect(), + ), + }) + } +} + +impl_from_repeated_copy!(Geoblock, Geoblocks); +impl_try_from_repeated!(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..8fd71287 --- /dev/null +++ b/metadata/src/playlist/operation.rs @@ -0,0 +1,113 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::{ + playlist::{ + attribute::{PlaylistUpdateAttributes, PlaylistUpdateItemAttributes}, + item::PlaylistItems, + }, + util::{impl_deref_wrapped, impl_try_from_repeated}, +}; + +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; +use protocol::playlist4_external::Rem as PlaylistRemoveMessage; +pub use protocol::playlist4_external::op::Kind as PlaylistOperationKind; + +#[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, Default)] +pub struct PlaylistOperations(pub Vec); + +impl_deref_wrapped!(PlaylistOperations, Vec); + +#[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.kind(), + add: operation.add.get_or_default().try_into()?, + rem: operation.rem.get_or_default().try_into()?, + mov: operation.mov.get_or_default().into(), + update_item_attributes: operation + .update_item_attributes + .get_or_default() + .try_into()?, + update_list_attributes: operation + .update_list_attributes + .get_or_default() + .try_into()?, + }) + } +} + +impl_try_from_repeated!(PlaylistOperationMessage, PlaylistOperations); + +impl TryFrom<&PlaylistAddMessage> for PlaylistOperationAdd { + type Error = librespot_core::Error; + fn try_from(add: &PlaylistAddMessage) -> Result { + Ok(Self { + from_index: add.from_index(), + items: add.items.as_slice().try_into()?, + add_last: add.add_last(), + add_first: add.add_first(), + }) + } +} + +impl From<&PlaylistMoveMessage> for PlaylistOperationMove { + fn from(mov: &PlaylistMoveMessage) -> Self { + Self { + from_index: mov.from_index(), + length: mov.length(), + to_index: mov.to_index(), + } + } +} + +impl TryFrom<&PlaylistRemoveMessage> for PlaylistOperationRemove { + type Error = librespot_core::Error; + fn try_from(remove: &PlaylistRemoveMessage) -> Result { + Ok(Self { + from_index: remove.from_index(), + length: remove.length(), + items: remove.items.as_slice().try_into()?, + has_items_as_key: remove.items_as_key(), + }) + } +} diff --git a/metadata/src/playlist/permission.rs b/metadata/src/playlist/permission.rs new file mode 100644 index 00000000..ebb179a8 --- /dev/null +++ b/metadata/src/playlist/permission.rs @@ -0,0 +1,46 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::util::{impl_deref_wrapped, impl_from_repeated_copy}; + +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, Default)] +pub struct PermissionLevels(pub Vec); + +impl_deref_wrapped!(PermissionLevels, Vec); + +impl From<&CapabilitiesMessage> for Capabilities { + fn from(playlist: &CapabilitiesMessage) -> Self { + Self { + can_view: playlist.can_view(), + can_administrate_permissions: playlist.can_administrate_permissions(), + grantable_levels: PermissionLevels( + playlist + .grantable_level + .iter() + .map(|l| l.enum_value_or_default()) + .collect(), + ), + can_edit_metadata: playlist.can_edit_metadata(), + can_edit_items: playlist.can_edit_items(), + can_cancel_membership: playlist.can_cancel_membership(), + } + } +} + +impl_from_repeated_copy!(PermissionLevel, PermissionLevels); diff --git a/metadata/src/request.rs b/metadata/src/request.rs new file mode 100644 index 00000000..720c3836 --- /dev/null +++ b/metadata/src/request.rs @@ -0,0 +1,37 @@ +use std::fmt::Write; + +use crate::MetadataError; + +use librespot_core::{Error, Session}; + +pub type RequestResult = Result; + +#[async_trait] +pub trait MercuryRequest { + async fn request(session: &Session, uri: &str) -> RequestResult { + let mut metrics_uri = uri.to_owned(); + + let separator = match metrics_uri.find('?') { + Some(_) => "&", + None => "?", + }; + let _ = write!(metrics_uri, "{separator}country={}", session.country()); + + if let Some(product) = session.get_user_attribute("type") { + let _ = write!(metrics_uri, "&product={product}"); + } + + trace!("Requesting {metrics_uri}"); + + let request = session.mercury().get(metrics_uri)?; + let response = request.await?; + match response.payload.first() { + Some(data) => { + let data = data.to_vec().into(); + trace!("Received metadata: {data:?}"); + Ok(data) + } + None => Err(Error::unavailable(MetadataError::Empty)), + } + } +} diff --git a/metadata/src/restriction.rs b/metadata/src/restriction.rs new file mode 100644 index 00000000..df9de425 --- /dev/null +++ b/metadata/src/restriction.rs @@ -0,0 +1,103 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::util::impl_deref_wrapped; +use crate::util::{impl_from_repeated, impl_from_repeated_copy}; + +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, Default)] +pub struct Restrictions(pub Vec); + +impl_deref_wrapped!(Restrictions, Vec); + +#[derive(Debug, Clone)] +pub struct RestrictionCatalogues(pub Vec); + +impl_deref_wrapped!(RestrictionCatalogues, Vec); + +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.countries_allowed())) + } else { + None + }; + + let countries_forbidden = if restriction.has_countries_forbidden() { + Some(Self::parse_country_codes(restriction.countries_forbidden())) + } else { + None + }; + + Self { + catalogues: restriction + .catalogue + .iter() + .map(|c| c.enum_value_or_default()) + .collect::>() + .as_slice() + .into(), + restriction_type: restriction + .type_ + .unwrap_or_default() + .enum_value_or_default(), + catalogue_strs: restriction.catalogue_str.to_vec(), + countries_allowed, + countries_forbidden, + } + } +} + +impl_from_repeated!(RestrictionMessage, Restrictions); +impl_from_repeated_copy!(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..fa8a1183 --- /dev/null +++ b/metadata/src/sale_period.rs @@ -0,0 +1,39 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::{ + restriction::Restrictions, + util::{impl_deref_wrapped, impl_try_from_repeated}, +}; + +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, Default)] +pub struct SalePeriods(pub Vec); + +impl_deref_wrapped!(SalePeriods, Vec); + +impl TryFrom<&SalePeriodMessage> for SalePeriod { + type Error = librespot_core::Error; + fn try_from(sale_period: &SalePeriodMessage) -> Result { + Ok(Self { + restrictions: sale_period.restriction.as_slice().into(), + start: sale_period.start.get_or_default().try_into()?, + end: sale_period.end.get_or_default().try_into()?, + }) + } +} + +impl_try_from_repeated!(SalePeriodMessage, SalePeriods); diff --git a/metadata/src/show.rs b/metadata/src/show.rs new file mode 100644 index 00000000..01a55c2d --- /dev/null +++ b/metadata/src/show.rs @@ -0,0 +1,80 @@ +use std::fmt::Debug; + +use crate::{ + Metadata, RequestResult, availability::Availabilities, copyright::Copyrights, + episode::Episodes, image::Images, restriction::Restrictions, +}; + +use librespot_core::{Error, Session, SpotifyUri}; + +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: SpotifyUri, + 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: Option, + 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_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Show { id: show_id } = show_uri else { + return Err(Error::invalid_argument("show_uri")); + }; + + session.spclient().get_show_metadata(show_id).await + } + + fn parse(msg: &Self::Message, _: &SpotifyUri) -> 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.name().to_owned(), + description: show.description().to_owned(), + publisher: show.publisher().to_owned(), + language: show.language().to_owned(), + is_explicit: show.explicit(), + covers: show.cover_image.image.as_slice().into(), + episodes: show.episode.as_slice().try_into()?, + copyrights: show.copyright.as_slice().into(), + restrictions: show.restriction.as_slice().into(), + keywords: show.keyword.to_vec(), + media_type: show.media_type(), + consumption_order: show.consumption_order(), + availability: show.availability.as_slice().try_into()?, + trailer_uri: show + .trailer_uri + .as_deref() + .filter(|s| !s.is_empty()) + .map(SpotifyUri::from_uri) + .transpose()?, + has_music_and_talk: show.music_and_talk(), + is_audiobook: show.is_audiobook(), + }) + } +} diff --git a/metadata/src/track.rs b/metadata/src/track.rs new file mode 100644 index 00000000..5893ca15 --- /dev/null +++ b/metadata/src/track.rs @@ -0,0 +1,107 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use uuid::Uuid; + +use crate::{ + Album, Metadata, RequestResult, + artist::{Artists, ArtistsWithRole}, + audio::file::AudioFiles, + availability::Availabilities, + content_rating::ContentRatings, + external_id::ExternalIds, + restriction::Restrictions, + sale_period::SalePeriods, + util::{impl_deref_wrapped, impl_try_from_repeated}, +}; + +use librespot_core::{Error, Session, SpotifyUri, date::Date}; +use librespot_protocol as protocol; + +#[derive(Debug, Clone)] +pub struct Track { + pub id: SpotifyUri, + pub name: String, + pub album: Album, + 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, Default)] +pub struct Tracks(pub Vec); + +impl_deref_wrapped!(Tracks, Vec); + +#[async_trait] +impl Metadata for Track { + type Message = protocol::metadata::Track; + + async fn request(session: &Session, track_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Track { id: track_id } = track_uri else { + return Err(Error::invalid_argument("track_uri")); + }; + + session.spclient().get_track_metadata(track_id).await + } + + fn parse(msg: &Self::Message, _: &SpotifyUri) -> 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.name().to_owned(), + album: track.album.get_or_default().try_into()?, + artists: track.artist.as_slice().try_into()?, + number: track.number(), + disc_number: track.disc_number(), + duration: track.duration(), + popularity: track.popularity(), + is_explicit: track.explicit(), + external_ids: track.external_id.as_slice().into(), + restrictions: track.restriction.as_slice().into(), + files: track.file.as_slice().into(), + alternatives: track.alternative.as_slice().try_into()?, + sale_periods: track.sale_period.as_slice().try_into()?, + previews: track.preview.as_slice().into(), + tags: track.tags.to_vec(), + earliest_live_timestamp: Date::from_timestamp_ms(track.earliest_live_timestamp())?, + has_lyrics: track.has_lyrics(), + availability: track.availability.as_slice().try_into()?, + licensor: Uuid::from_slice(track.licensor.uuid()).unwrap_or_else(|_| Uuid::nil()), + language_of_performance: track.language_of_performance.to_vec(), + content_ratings: track.content_rating.as_slice().into(), + original_title: track.original_title().to_owned(), + version_title: track.version_title().to_owned(), + artists_with_role: track.artist_with_role.as_slice().try_into()?, + }) + } +} + +impl_try_from_repeated!(::Message, Tracks); diff --git a/metadata/src/util.rs b/metadata/src/util.rs new file mode 100644 index 00000000..f6866340 --- /dev/null +++ b/metadata/src/util.rs @@ -0,0 +1,58 @@ +macro_rules! impl_from_repeated { + ($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 impl_from_repeated; + +macro_rules! impl_from_repeated_copy { + ($src:ty, $dst:ty) => { + impl From<&[$src]> for $dst { + fn from(src: &[$src]) -> Self { + let result = src.iter().copied().collect(); + Self(result) + } + } + }; +} + +pub(crate) use impl_from_repeated_copy; + +macro_rules! impl_try_from_repeated { + ($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 impl_try_from_repeated; + +macro_rules! impl_deref_wrapped { + ($wrapper:ty, $inner:ty) => { + impl Deref for $wrapper { + type Target = $inner; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl DerefMut for $wrapper { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + }; +} + +pub(crate) use impl_deref_wrapped; diff --git a/metadata/src/video.rs b/metadata/src/video.rs new file mode 100644 index 00000000..df634f23 --- /dev/null +++ b/metadata/src/video.rs @@ -0,0 +1,18 @@ +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, +}; + +use crate::util::{impl_deref_wrapped, impl_from_repeated}; + +use librespot_core::FileId; + +use librespot_protocol as protocol; +use protocol::metadata::VideoFile as VideoFileMessage; + +#[derive(Debug, Clone, Default)] +pub struct VideoFiles(pub Vec); + +impl_deref_wrapped!(VideoFiles, Vec); + +impl_from_repeated!(VideoFileMessage, VideoFiles); diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml new file mode 100644 index 00000000..31d58df0 --- /dev/null +++ b/oauth/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "librespot-oauth" +version.workspace = true +rust-version.workspace = true +authors = ["Nick Steel "] +license.workspace = true +description = "OAuth authorization code flow with PKCE for obtaining a Spotify access token" +repository.workspace = true +edition.workspace = true + +[features] +# Refer to the workspace Cargo.toml for the list of features +default = ["native-tls"] + +# TLS backends (mutually exclusive - compile-time checks in src/lib.rs) +native-tls = ["oauth2/native-tls", "reqwest/native-tls"] +rustls-tls-native-roots = [ + "__rustls", + "oauth2/rustls-tls", + "reqwest/rustls-tls-native-roots", +] +rustls-tls-webpki-roots = [ + "__rustls", + "oauth2/rustls-tls", + "reqwest/rustls-tls-webpki-roots", +] + +# Internal features - these are not meant to be used by end users +__rustls = [] + +[dependencies] +log = "0.4" +oauth2 = { version = "5.0", default-features = false, features = [ + "reqwest", + "reqwest-blocking", +] } +open = "5.3" +reqwest = { version = "0.12", default-features = false, features = [ + "system-proxy", +] } +thiserror = "2" +url = "2.5" + +[dev-dependencies] +env_logger = { version = "0.11", default-features = false, features = [ + "color", + "humantime", + "auto-color", +] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/oauth/examples/oauth_async.rs b/oauth/examples/oauth_async.rs new file mode 100644 index 00000000..4d357535 --- /dev/null +++ b/oauth/examples/oauth_async.rs @@ -0,0 +1,65 @@ +use std::env; + +use librespot_oauth::OAuthClientBuilder; + +const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +const SPOTIFY_REDIRECT_URI: &str = "http://127.0.0.1:8898/login"; + +const RESPONSE: &str = r#" + + + +

Return to your app!

+ + +"#; + +#[tokio::main] +async fn main() { + let mut builder = env_logger::Builder::new(); + builder.parse_filters("librespot=trace"); + builder.init(); + + let args: Vec<_> = env::args().collect(); + let (client_id, redirect_uri, scopes) = if args.len() == 4 { + // You can use your own client ID, along with it's associated redirect URI. + ( + args[1].as_str(), + args[2].as_str(), + args[3].split(',').collect::>(), + ) + } else if args.len() == 1 { + (SPOTIFY_CLIENT_ID, SPOTIFY_REDIRECT_URI, vec!["streaming"]) + } else { + eprintln!("Usage: {} [CLIENT_ID REDIRECT_URI SCOPES]", args[0]); + return; + }; + + let client = match OAuthClientBuilder::new(client_id, redirect_uri, scopes) + .open_in_browser() + .with_custom_message(RESPONSE) + .build() + { + Ok(client) => client, + Err(err) => { + eprintln!("Unable to build an OAuth client: {err}"); + return; + } + }; + + let refresh_token = match client.get_access_token_async().await { + Ok(token) => { + println!("OAuth Token: {token:#?}"); + token.refresh_token + } + Err(err) => { + println!("Unable to get OAuth Token: {err}"); + return; + } + }; + + match client.refresh_token_async(&refresh_token).await { + Ok(token) => println!("New refreshed OAuth Token: {token:#?}"), + Err(err) => println!("Unable to get refreshed OAuth Token: {err}"), + } +} diff --git a/oauth/examples/oauth_sync.rs b/oauth/examples/oauth_sync.rs new file mode 100644 index 00000000..c052faac --- /dev/null +++ b/oauth/examples/oauth_sync.rs @@ -0,0 +1,64 @@ +use std::env; + +use librespot_oauth::OAuthClientBuilder; + +const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +const SPOTIFY_REDIRECT_URI: &str = "http://127.0.0.1:8898/login"; + +const RESPONSE: &str = r#" + + + +

Return to your app!

+ + +"#; + +fn main() { + let mut builder = env_logger::Builder::new(); + builder.parse_filters("librespot=trace"); + builder.init(); + + let args: Vec<_> = env::args().collect(); + let (client_id, redirect_uri, scopes) = if args.len() == 4 { + // You can use your own client ID, along with it's associated redirect URI. + ( + args[1].as_str(), + args[2].as_str(), + args[3].split(',').collect::>(), + ) + } else if args.len() == 1 { + (SPOTIFY_CLIENT_ID, SPOTIFY_REDIRECT_URI, vec!["streaming"]) + } else { + eprintln!("Usage: {} [CLIENT_ID REDIRECT_URI SCOPES]", args[0]); + return; + }; + + let client = match OAuthClientBuilder::new(client_id, redirect_uri, scopes) + .open_in_browser() + .with_custom_message(RESPONSE) + .build() + { + Ok(client) => client, + Err(err) => { + eprintln!("Unable to build an OAuth client: {err}"); + return; + } + }; + + let refresh_token = match client.get_access_token() { + Ok(token) => { + println!("OAuth Token: {token:#?}"); + token.refresh_token + } + Err(err) => { + println!("Unable to get OAuth Token: {err}"); + return; + } + }; + + match client.refresh_token(&refresh_token) { + Ok(token) => println!("New refreshed OAuth Token: {token:#?}"), + Err(err) => println!("Unable to get refreshed OAuth Token: {err}"), + } +} diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs new file mode 100644 index 00000000..70e83339 --- /dev/null +++ b/oauth/src/lib.rs @@ -0,0 +1,561 @@ +#![warn(missing_docs)] +//! Provides a Spotify access token using the OAuth authorization code flow +//! with PKCE. +//! +//! Assuming sufficient scopes, the returned access token may be used with Spotify's +//! Web API, and/or to establish a new Session with [`librespot_core`]. +//! +//! The authorization code flow is an interactive process which requires a web browser +//! to complete. The resulting code must then be provided back from the browser to this +//! library for exchange into an access token. Providing the code can be automatic via +//! a spawned http server (mimicking Spotify's client), or manually via stdin. The latter +//! is appropriate for headless systems. + +use std::{ + io::{self, BufRead, BufReader, Write}, + net::{SocketAddr, TcpListener}, + sync::mpsc, + time::{Duration, Instant}, +}; + +use oauth2::{ + AuthUrl, AuthorizationCode, ClientId, CsrfToken, EmptyExtraTokenFields, EndpointNotSet, + EndpointSet, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, + StandardTokenResponse, TokenResponse, TokenUrl, basic::BasicClient, basic::BasicTokenType, +}; + +use log::{error, info, trace}; +use thiserror::Error; +use url::Url; + +// TLS Feature Validation +// +// These compile-time checks are placed in the oauth crate rather than core for a specific reason: +// oauth is at the bottom of the dependency tree (even librespot-core depends on librespot-oauth), +// which means it gets compiled first. This ensures TLS feature conflicts are detected early in +// the build process, providing immediate feedback to users rather than failing later during +// core compilation. +// +// The dependency chain is: workspace -> core -> oauth +// So oauth's feature validation runs before core's, catching configuration errors quickly. + +#[cfg(all(feature = "native-tls", feature = "__rustls"))] +compile_error!( + "Feature \"native-tls\" is mutually exclusive with \"rustls-tls-native-roots\" and \"rustls-tls-webpki-roots\". Enable only one." +); + +#[cfg(not(any(feature = "native-tls", feature = "__rustls")))] +compile_error!( + "Either feature \"native-tls\" (default), \"rustls-tls-native-roots\" or \"rustls-tls-webpki-roots\" must be enabled for this crate." +); + +/// Possible errors encountered during the OAuth authentication flow. +#[derive(Debug, Error)] +pub enum OAuthError { + /// The redirect URI cannot be parsed as a valid URL. + #[error("Unable to parse redirect URI {uri} ({e})")] + AuthCodeBadUri { + /// Auth URI. + uri: String, + /// Inner error code. + e: url::ParseError, + }, + + /// The authorization code parameter is missing in the redirect URI. + #[error("Auth code param not found in URI {uri}")] + AuthCodeNotFound { + /// Auth URI. + uri: String, + }, + + /// Failed to read input from standard input when manually collecting auth code. + #[error("Failed to read redirect URI from stdin")] + AuthCodeStdinRead, + + /// Could not bind TCP listener to the specified socket address for OAuth callback. + #[error("Failed to bind server to {addr} ({e})")] + AuthCodeListenerBind { + /// Callback address. + addr: SocketAddr, + /// Inner error code. + e: io::Error, + }, + + /// Listener terminated before receiving an OAuth callback connection. + #[error("Listener terminated without accepting a connection")] + AuthCodeListenerTerminated, + + /// Failed to read incoming HTTP request containing OAuth callback. + #[error("Failed to read redirect URI from HTTP request")] + AuthCodeListenerRead, + + /// Received malformed HTTP request for OAuth callback. + #[error("Failed to parse redirect URI from HTTP request")] + AuthCodeListenerParse, + + /// Could not send HTTP response after handling OAuth callback. + #[error("Failed to write HTTP response")] + AuthCodeListenerWrite, + + /// Invalid Spotify authorization endpoint URL. + #[error("Invalid Spotify OAuth URI")] + InvalidSpotifyUri, + + /// Redirect URI failed validation. + #[error("Invalid Redirect URI {uri} ({e})")] + InvalidRedirectUri { + /// Auth URI. + uri: String, + /// Inner error code + e: url::ParseError, + }, + + /// Channel communication failure. + #[error("Failed to receive code")] + Recv, + + /// Token exchange failure with Spotify's authorization server. + #[error("Failed to exchange code for access token ({e})")] + ExchangeCode { + /// Inner error description + e: String, + }, +} + +/// Represents an OAuth token used for accessing Spotify's Web API and sessions. +#[derive(Debug, Clone)] +pub struct OAuthToken { + /// Bearer token used for authenticated Spotify API requests + pub access_token: String, + /// Long-lived token used to obtain new access tokens + pub refresh_token: String, + /// Instant when the access token becomes invalid + pub expires_at: Instant, + /// Type of token + pub token_type: String, + /// Permission scopes granted by this token + pub scopes: Vec, +} + +/// Return code query-string parameter from the redirect URI. +fn get_code(redirect_url: &str) -> Result { + let url = Url::parse(redirect_url).map_err(|e| OAuthError::AuthCodeBadUri { + uri: redirect_url.to_string(), + e, + })?; + let code = url + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, code)| AuthorizationCode::new(code.into_owned())) + .ok_or(OAuthError::AuthCodeNotFound { + uri: redirect_url.to_string(), + })?; + + Ok(code) +} + +/// Prompt for redirect URI on stdin and return auth code. +fn get_authcode_stdin() -> Result { + println!("Provide redirect URL"); + let mut buffer = String::new(); + let stdin = io::stdin(); + stdin + .read_line(&mut buffer) + .map_err(|_| OAuthError::AuthCodeStdinRead)?; + + get_code(buffer.trim()) +} + +/// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code. +fn get_authcode_listener( + socket_address: SocketAddr, + message: String, +) -> Result { + let listener = + TcpListener::bind(socket_address).map_err(|e| OAuthError::AuthCodeListenerBind { + addr: socket_address, + e, + })?; + info!("OAuth server listening on {socket_address:?}"); + + // The server will terminate itself after collecting the first code. + let mut stream = listener + .incoming() + .flatten() + .next() + .ok_or(OAuthError::AuthCodeListenerTerminated)?; + let mut reader = BufReader::new(&stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .map_err(|_| OAuthError::AuthCodeListenerRead)?; + + let redirect_url = request_line + .split_whitespace() + .nth(1) + .ok_or(OAuthError::AuthCodeListenerParse)?; + let code = get_code(&("http://localhost".to_string() + redirect_url)); + + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + message.len(), + message + ); + stream + .write_all(response.as_bytes()) + .map_err(|_| OAuthError::AuthCodeListenerWrite)?; + + code +} + +// If the specified `redirect_uri` is HTTP and contains a port, +// then the corresponding socket address is returned. +fn get_socket_address(redirect_uri: &str) -> Option { + let url = match Url::parse(redirect_uri) { + Ok(u) if u.scheme() == "http" && u.port().is_some() => u, + _ => return None, + }; + match url.socket_addrs(|| None) { + Ok(mut addrs) => addrs.pop(), + _ => None, + } +} + +/// Struct that handle obtaining and refreshing access tokens. +pub struct OAuthClient { + scopes: Vec, + redirect_uri: String, + should_open_url: bool, + message: String, + client: BasicClient, +} + +impl OAuthClient { + /// Generates and opens/shows the authorization URL to obtain an access token. + /// + /// Returns a verifier that must be included in the final request for validation. + fn set_auth_url(&self) -> PkceCodeVerifier { + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + // Generate the full authorization URL. + // Some of these scopes are unavailable for custom client IDs. Which? + let request_scopes: Vec = + self.scopes.iter().map(|s| Scope::new(s.into())).collect(); + let (auth_url, _) = self + .client + .authorize_url(CsrfToken::new_random) + .add_scopes(request_scopes) + .set_pkce_challenge(pkce_challenge) + .url(); + + if self.should_open_url { + open::that_in_background(auth_url.as_str()); + } + println!("Browse to: {auth_url}"); + + pkce_verifier + } + + fn build_token( + &self, + resp: StandardTokenResponse, + ) -> Result { + trace!("Obtained new access token: {resp:?}"); + + let token_scopes: Vec = match resp.scopes() { + Some(s) => s.iter().map(|s| s.to_string()).collect(), + _ => self.scopes.clone(), + }; + let refresh_token = match resp.refresh_token() { + Some(t) => t.secret().to_string(), + _ => "".to_string(), // Spotify always provides a refresh token. + }; + Ok(OAuthToken { + access_token: resp.access_token().secret().to_string(), + refresh_token, + expires_at: Instant::now() + + resp + .expires_in() + .unwrap_or_else(|| Duration::from_secs(3600)), + token_type: format!("{:?}", resp.token_type()), + scopes: token_scopes, + }) + } + + /// Syncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow. + pub fn get_access_token(&self) -> Result { + let pkce_verifier = self.set_auth_url(); + + let code = match get_socket_address(&self.redirect_uri) { + Some(addr) => get_authcode_listener(addr, self.message.clone()), + _ => get_authcode_stdin(), + }?; + trace!("Exchange {code:?} for access token"); + + let (tx, rx) = mpsc::channel(); + let client = self.client.clone(); + std::thread::spawn(move || { + let http_client = reqwest::blocking::Client::new(); + let resp = client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(&http_client); + if let Err(e) = tx.send(resp) { + error!("OAuth channel send error: {e}"); + } + }); + let channel_response = rx.recv().map_err(|_| OAuthError::Recv)?; + let token_response = + channel_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + + self.build_token(token_response) + } + + /// Synchronously obtain a new valid OAuth token from `refresh_token` + pub fn refresh_token(&self, refresh_token: &str) -> Result { + let refresh_token = RefreshToken::new(refresh_token.to_string()); + let http_client = reqwest::blocking::Client::new(); + let resp = self + .client + .exchange_refresh_token(&refresh_token) + .request(&http_client); + + let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) + } + + /// Asyncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow. + pub async fn get_access_token_async(&self) -> Result { + let pkce_verifier = self.set_auth_url(); + + let code = match get_socket_address(&self.redirect_uri) { + Some(addr) => get_authcode_listener(addr, self.message.clone()), + _ => get_authcode_stdin(), + }?; + trace!("Exchange {code:?} for access token"); + + let http_client = reqwest::Client::new(); + let resp = self + .client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request_async(&http_client) + .await; + + let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) + } + + /// Asynchronously obtain a new valid OAuth token from `refresh_token` + pub async fn refresh_token_async(&self, refresh_token: &str) -> Result { + let refresh_token = RefreshToken::new(refresh_token.to_string()); + let http_client = reqwest::Client::new(); + let resp = self + .client + .exchange_refresh_token(&refresh_token) + .request_async(&http_client) + .await; + + let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) + } +} + +/// Builder struct through which structures of type OAuthClient are instantiated. +pub struct OAuthClientBuilder { + client_id: String, + redirect_uri: String, + scopes: Vec, + should_open_url: bool, + message: String, +} + +impl OAuthClientBuilder { + /// Create a new OAuthClientBuilder with provided params and default config. + /// + /// `redirect_uri` must match to the registered Uris of `client_id` + pub fn new(client_id: &str, redirect_uri: &str, scopes: Vec<&str>) -> Self { + Self { + client_id: client_id.to_string(), + redirect_uri: redirect_uri.to_string(), + scopes: scopes.into_iter().map(Into::into).collect(), + should_open_url: false, + message: String::from("Go back to your terminal :)"), + } + } + + /// When this function is added to the building process pipeline, the auth url will be + /// opened with the default web browser. Otherwise, it will be printed to standard output. + pub fn open_in_browser(mut self) -> Self { + self.should_open_url = true; + self + } + + /// When this function is added to the building process pipeline, the body of the response to + /// the callback request will be `message`. This is useful to load custom HTMLs to that &str. + pub fn with_custom_message(mut self, message: &str) -> Self { + self.message = message.to_string(); + self + } + + /// End of the building process pipeline. If Ok, a OAuthClient instance will be returned. + pub fn build(self) -> Result { + let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let redirect_url = RedirectUrl::new(self.redirect_uri.clone()).map_err(|e| { + OAuthError::InvalidRedirectUri { + uri: self.redirect_uri.clone(), + e, + } + })?; + + let client = BasicClient::new(ClientId::new(self.client_id.to_string())) + .set_auth_uri(auth_url) + .set_token_uri(token_url) + .set_redirect_uri(redirect_url); + + Ok(OAuthClient { + scopes: self.scopes, + should_open_url: self.should_open_url, + message: self.message, + redirect_uri: self.redirect_uri, + client, + }) + } +} + +/// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. +/// The `redirect_uri` must match what is registered to the client ID. +#[deprecated( + since = "0.7.0", + note = "please use builder pattern with `OAuthClientBuilder` instead" +)] +/// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. +/// The redirect_uri must match what is registered to the client ID. +pub fn get_access_token( + client_id: &str, + redirect_uri: &str, + scopes: Vec<&str>, +) -> Result { + let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let redirect_url = + RedirectUrl::new(redirect_uri.to_string()).map_err(|e| OAuthError::InvalidRedirectUri { + uri: redirect_uri.to_string(), + e, + })?; + let client = BasicClient::new(ClientId::new(client_id.to_string())) + .set_auth_uri(auth_url) + .set_token_uri(token_url) + .set_redirect_uri(redirect_url); + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + // Generate the full authorization URL. + // Some of these scopes are unavailable for custom client IDs. Which? + let request_scopes: Vec = scopes + .clone() + .into_iter() + .map(|s| Scope::new(s.into())) + .collect(); + let (auth_url, _) = client + .authorize_url(CsrfToken::new_random) + .add_scopes(request_scopes) + .set_pkce_challenge(pkce_challenge) + .url(); + + println!("Browse to: {auth_url}"); + + let code = match get_socket_address(redirect_uri) { + Some(addr) => get_authcode_listener(addr, String::from("Go back to your terminal :)")), + _ => get_authcode_stdin(), + }?; + trace!("Exchange {code:?} for access token"); + + // Do this sync in another thread because I am too stupid to make the async version work. + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let http_client = reqwest::blocking::Client::new(); + let resp = client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(&http_client); + if let Err(e) = tx.send(resp) { + error!("OAuth channel send error: {e}"); + } + }); + let token_response = rx.recv().map_err(|_| OAuthError::Recv)?; + let token = token_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + trace!("Obtained new access token: {token:?}"); + + let token_scopes: Vec = match token.scopes() { + Some(s) => s.iter().map(|s| s.to_string()).collect(), + _ => scopes.into_iter().map(|s| s.to_string()).collect(), + }; + let refresh_token = match token.refresh_token() { + Some(t) => t.secret().to_string(), + _ => "".to_string(), // Spotify always provides a refresh token. + }; + Ok(OAuthToken { + access_token: token.access_token().secret().to_string(), + refresh_token, + expires_at: Instant::now() + + token + .expires_in() + .unwrap_or_else(|| Duration::from_secs(3600)), + token_type: format!("{:?}", token.token_type()).to_string(), // Urgh!? + scopes: token_scopes, + }) +} + +#[cfg(test)] +mod test { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use super::*; + + #[test] + fn get_socket_address_none() { + // No port + assert_eq!(get_socket_address("http://127.0.0.1/foo"), None); + assert_eq!(get_socket_address("http://127.0.0.1:/foo"), None); + assert_eq!(get_socket_address("http://[::1]/foo"), None); + // Not http + assert_eq!(get_socket_address("https://127.0.0.1/foo"), None); + } + + #[test] + fn get_socket_address_some() { + let localhost_v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1234); + let localhost_v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 8888); + let addr_v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 1234); + let addr_v6 = SocketAddr::new( + IpAddr::V6(Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888)), + 8888, + ); + + // Loopback addresses + assert_eq!( + get_socket_address("http://127.0.0.1:1234/foo"), + Some(localhost_v4) + ); + assert_eq!( + get_socket_address("http://[0:0:0:0:0:0:0:1]:8888/foo"), + Some(localhost_v6) + ); + assert_eq!( + get_socket_address("http://[::1]:8888/foo"), + Some(localhost_v6) + ); + + // Non-loopback addresses + assert_eq!(get_socket_address("http://8.8.8.8:1234/foo"), Some(addr_v4)); + assert_eq!( + get_socket_address("http://[2001:4860:4860::8888]:8888/foo"), + Some(addr_v6) + ); + } +} diff --git a/playback/Cargo.toml b/playback/Cargo.toml index 4e8d19c6..2001c680 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -1,61 +1,95 @@ [package] name = "librespot-playback" -version = "0.3.1" +version.workspace = true +rust-version.workspace = true authors = ["Sasha Hilton "] +license.workspace = true description = "The audio playback logic for librespot" -license = "MIT" -repository = "https://github.com/librespot-org/librespot" -edition = "2018" - -[dependencies.librespot-audio] -path = "../audio" -version = "0.3.1" -[dependencies.librespot-core] -path = "../core" -version = "0.3.1" -[dependencies.librespot-metadata] -path = "../metadata" -version = "0.3.1" - -[dependencies] -futures-executor = "0.3" -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"] } -zerocopy = { version = "0.3" } -thiserror = { version = "1" } - -# Backends -alsa = { version = "0.5", optional = true } -portaudio-rs = { version = "0.3", optional = true } -libpulse-binding = { version = "2", optional = true, default-features = false } -libpulse-simple-binding = { version = "2", optional = true, default-features = false } -jack = { version = "0.7", optional = true } -sdl2 = { version = "0.34.3", optional = true } -gstreamer = { version = "0.16", optional = true } -gstreamer-app = { version = "0.16", optional = true } -glib = { version = "0.10", optional = true } - -# Rodio dependencies -rodio = { version = "0.14", optional = true, default-features = false } -cpal = { version = "0.13", optional = true } - -# Decoder -lewton = "0.10" -ogg = "0.8" - -# Dithering -rand = { version = "0.8", features = ["small_rng"] } -rand_distr = "0.4" +repository.workspace = true +edition.workspace = true [features] -alsa-backend = ["alsa"] -portaudio-backend = ["portaudio-rs"] -pulseaudio-backend = ["libpulse-binding", "libpulse-simple-binding"] -jackaudio-backend = ["jack"] -rodio-backend = ["rodio", "cpal"] -rodiojack-backend = ["rodio", "cpal/jack"] -sdl-backend = ["sdl2"] -gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"] +# Refer to the workspace Cargo.toml for the list of features +default = ["rodio-backend", "native-tls"] + +# Audio backends +alsa-backend = ["dep:alsa"] +gstreamer-backend = [ + "dep:gstreamer", + "dep:gstreamer-app", + "dep:gstreamer-audio", +] +jackaudio-backend = ["dep:jack"] +portaudio-backend = ["dep:portaudio-rs"] +pulseaudio-backend = ["dep:libpulse-binding", "dep:libpulse-simple-binding"] +rodio-backend = ["dep:cpal", "dep:rodio"] +rodiojack-backend = ["dep:rodio", "cpal/jack"] +sdl-backend = ["dep:sdl2"] + +# Audio processing features +passthrough-decoder = ["dep:ogg"] + +# TLS backend propagation +native-tls = [ + "librespot-core/native-tls", + "librespot-audio/native-tls", + "librespot-metadata/native-tls", +] +rustls-tls-native-roots = [ + "librespot-core/rustls-tls-native-roots", + "librespot-audio/rustls-tls-native-roots", + "librespot-metadata/rustls-tls-native-roots", +] +rustls-tls-webpki-roots = [ + "librespot-core/rustls-tls-webpki-roots", + "librespot-audio/rustls-tls-webpki-roots", + "librespot-metadata/rustls-tls-webpki-roots", +] + +[dependencies] +librespot-audio = { version = "0.7.1", path = "../audio", default-features = false } +librespot-core = { version = "0.7.1", path = "../core", default-features = false } +librespot-metadata = { version = "0.7.1", path = "../metadata", default-features = false } + +futures-util = { version = "0.3", default-features = false, features = ["std"] } +log = "0.4" +portable-atomic = "1" +shell-words = "1.1" +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "sync"] } +zerocopy = { version = "0.8", features = ["derive"] } + +# Backends +alsa = { version = "0.10", optional = true } +jack = { version = "0.13", optional = true } +portaudio-rs = { version = "0.3", optional = true } +sdl2 = { version = "0.38", optional = true } + +# GStreamer dependencies +gstreamer = { version = "0.24", optional = true } +gstreamer-app = { version = "0.24", optional = true } +gstreamer-audio = { version = "0.24", optional = true } + +# PulseAudio dependencies +libpulse-binding = { version = "2", optional = true, default-features = false } +libpulse-simple-binding = { version = "2", optional = true, default-features = false } + +# Rodio dependencies +cpal = { version = "0.16", optional = true } +rodio = { version = "0.21", optional = true, default-features = false, features = [ + "playback", +] } + +# Container and audio decoder +symphonia = { version = "0.5", default-features = false, features = [ + "mp3", + "ogg", + "vorbis", +] } + +# Legacy Ogg container decoder for the passthrough decoder +ogg = { version = "0.9", optional = true } + +# Dithering +rand = { version = "0.9", default-features = false, features = ["small_rng"] } +rand_distr = "0.5" diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index 9dd3ea0c..bd2b4bf5 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -4,16 +4,17 @@ 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::process::exit; -use std::time::Duration; use thiserror::Error; -// 0.5 sec buffer. -const PERIOD_TIME: Duration = Duration::from_millis(100); -const BUFFER_TIME: Duration = Duration::from_millis(500); +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 { @@ -60,8 +61,8 @@ enum AlsaError { #[error(" PCM, {0}")] Pcm(alsa::Error), - #[error(" Could Not Parse Ouput Name(s) and/or Description(s)")] - Parsing, + #[error(" Could Not Parse Output Name(s) and/or Description(s), {0}")] + Parsing(alsa::Error), #[error("")] NotConnected, @@ -80,6 +81,20 @@ impl From for SinkError { } } +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(), + S24_3 => Format::s24_3(), + S16 => Format::s16(), + } + } +} + pub struct AlsaSink { pcm: Option, format: AudioFormat, @@ -87,20 +102,59 @@ pub struct AlsaSink { period_buffer: Vec, } -fn list_outputs() -> SinkResult<()> { - println!("Listing available Alsa outputs:"); - for t in &["pcm", "ctl", "hwdep"] { - println!("{} devices:", t); +fn list_compatible_devices() -> SinkResult<()> { + let i = HintIter::new_str(None, "pcm").map_err(AlsaError::Parsing)?; - let i = HintIter::new_str(None, t).map_err(|_| AlsaError::Parsing)?; + println!("\n\n\tCompatible alsa device(s):\n"); + println!("\t------------------------------------------------------\n"); - for a in i { - if let Some(Direction::Playback) = a.direction { - // mimic aplay -L - let name = a.name.ok_or(AlsaError::Parsing)?; - let desc = a.desc.ok_or(AlsaError::Parsing)?; + for a in i { + if let Some(Direction::Playback) = a.direction { + if let Some(name) = a.name { + 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. - println!("{}\n\t{}\n", name, desc.replace("\n", "\n\t")); + 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() + { + 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:?}")); + } + } + + if !supported_formats.is_empty() { + println!("\tDevice:\n\n\t\t{name}\n"); + + println!( + "\tDescription:\n\n\t\t{}\n", + a.desc.unwrap_or_default().replace('\n', "\n\t\t") + ); + + println!( + "\tSupported Format(s):\n\n\t\t{}\n", + supported_formats.join(" ") + ); + + println!( + "\t------------------------------------------------------\n" + ); + } + } + }; + } } } } @@ -114,19 +168,6 @@ fn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> 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)?; @@ -136,6 +177,8 @@ fn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> e, })?; + let alsa_format = Format::from(format); + hwp.set_format(alsa_format) .map_err(|e| AlsaError::UnsupportedFormat { device: dev_name.to_string(), @@ -159,34 +202,185 @@ fn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> e, })?; - hwp.set_buffer_time_near(BUFFER_TIME.as_micros() as u32, 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, + }; + + 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 its 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 its 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: {min_period:?} (Buffer size / {MIN_PERIOD_DIVISOR:?}) - {max_period:?} (Buffer size / {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!("Frames per Buffer: {:?}", frames_per_buffer); - trace!("Frames per Period: {:?}", frames_per_period); + 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); + trace!("Period Buffer size in bytes: {bytes_per_period:?}"); Ok((pcm, bytes_per_period)) } @@ -194,12 +388,12 @@ fn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> 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(e) => { - error!("{}", e); + error!("{e}"); exit(1); } }, @@ -208,7 +402,7 @@ impl Open for AlsaSink { } .to_string(); - info!("Using AlsaSink with format: {:?}", format); + info!("Using AlsaSink with format: {format:?}"); Self { pcm: None, @@ -240,14 +434,16 @@ impl Sink for AlsaSink { } 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()?; + if self.pcm.is_some() { + // 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)?; + let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?; - pcm.drain().map_err(AlsaError::DrainFailure)?; + pcm.drain().map_err(AlsaError::DrainFailure)?; + } Ok(()) } @@ -256,6 +452,7 @@ impl Sink for AlsaSink { } impl SinkAsBytes for AlsaSink { + #[inline] fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { let mut start_index = 0; let data_len = data.len(); @@ -264,7 +461,7 @@ impl SinkAsBytes for AlsaSink { 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 data_to_buffer = data_left.min(space_left); let end_index = start_index + data_to_buffer; self.period_buffer @@ -287,17 +484,26 @@ impl AlsaSink { pub const NAME: &'static str = "alsa"; fn write_buf(&mut self) -> SinkResult<()> { - let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?; + if self.pcm.is_some() { + let write_result = { + 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, {}", - e - ); + match pcm.io_bytes().writei(&self.period_buffer) { + Ok(_) => Ok(()), + Err(e) => { + // 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, {e}"); - pcm.try_recover(e, false).map_err(AlsaError::OnWrite)? + pcm.try_recover(e, false).map_err(AlsaError::OnWrite) + } + } + }; + + if let Err(e) = write_result { + self.pcm = None; + return Err(e.into()); + } } self.period_buffer.clear(); diff --git a/playback/src/audio_backend/gstreamer.rs b/playback/src/audio_backend/gstreamer.rs index 8b957577..f41d4333 100644 --- a/playback/src/audio_backend/gstreamer.rs +++ b/playback/src/audio_backend/gstreamer.rs @@ -1,141 +1,212 @@ -use super::{Open, Sink, SinkAsBytes, SinkResult}; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; -use crate::{NUM_CHANNELS, SAMPLE_RATE}; +use std::sync::{Arc, Mutex}; + +use gstreamer::{ + State, + event::{FlushStart, FlushStop}, + prelude::*, +}; use gstreamer as gst; use gstreamer_app as gst_app; +use gstreamer_audio as gst_audio; -use gst::prelude::*; -use zerocopy::AsBytes; +const GSTREAMER_ASYNC_ERROR_POISON_MSG: &str = "gstreamer async error mutex should not be poisoned"; -use std::sync::mpsc::{sync_channel, SyncSender}; -use std::thread; +use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; + +use crate::{ + NUM_CHANNELS, SAMPLE_RATE, config::AudioFormat, convert::Converter, decoder::AudioPacket, +}; -#[allow(dead_code)] pub struct GstreamerSink { - tx: SyncSender>, + appsrc: gst_app::AppSrc, + bufferpool: gst::BufferPool, pipeline: gst::Pipeline, format: AudioFormat, + async_error: Arc>>, } impl Open for GstreamerSink { fn open(device: Option, format: AudioFormat) -> Self { - info!("Using GStreamer sink with format: {:?}", format); + info!("Using GStreamer sink with format: {format:?}"); gst::init().expect("failed to init GStreamer!"); - // GStreamer calls S24 and S24_3 different from the rest of the world let gst_format = match format { - AudioFormat::S24 => "S24_32".to_string(), - AudioFormat::S24_3 => "S24".to_string(), - _ => format!("{:?}", format), + AudioFormat::F64 => gst_audio::AUDIO_FORMAT_F64, + AudioFormat::F32 => gst_audio::AUDIO_FORMAT_F32, + AudioFormat::S32 => gst_audio::AUDIO_FORMAT_S32, + AudioFormat::S24 => gst_audio::AUDIO_FORMAT_S2432, + AudioFormat::S24_3 => gst_audio::AUDIO_FORMAT_S24, + AudioFormat::S16 => gst_audio::AUDIO_FORMAT_S16, }; + + let gst_info = gst_audio::AudioInfo::builder(gst_format, SAMPLE_RATE, NUM_CHANNELS as u32) + .build() + .expect("Failed to create GStreamer audio format"); + let gst_caps = gst_info.to_caps().expect("Failed to create GStreamer caps"); + let sample_size = format.size(); - let gst_bytes = 2048 * sample_size; + let gst_bytes = NUM_CHANNELS as usize * 2048 * sample_size; - #[cfg(target_endian = "little")] - const ENDIANNESS: &str = "LE"; - #[cfg(target_endian = "big")] - const ENDIANNESS: &str = "BE"; - - let pipeline_str_preamble = format!( - "appsrc caps=\"audio/x-raw,format={}{},layout=interleaved,channels={},rate={}\" block=true max-bytes={} name=appsrc0 ", - gst_format, ENDIANNESS, NUM_CHANNELS, SAMPLE_RATE, gst_bytes - ); - // no need to dither twice; use librespot dithering instead - let pipeline_str_rest = r#" ! audioconvert dithering=none ! autoaudiosink"#; - let pipeline_str: String = match device { - Some(x) => format!("{}{}", pipeline_str_preamble, x), - None => format!("{}{}", pipeline_str_preamble, pipeline_str_rest), - }; - info!("Pipeline: {}", pipeline_str); - - gst::init().unwrap(); - let pipelinee = gst::parse_launch(&*pipeline_str).expect("Couldn't launch pipeline; likely a GStreamer issue or an error in the pipeline string you specified in the 'device' argument to librespot."); - let pipeline = pipelinee - .dynamic_cast::() - .expect("couldn't cast pipeline element at runtime!"); - let bus = pipeline.get_bus().expect("couldn't get bus from pipeline"); - let mainloop = glib::MainLoop::new(None, false); - let appsrce: gst::Element = pipeline - .get_by_name("appsrc0") - .expect("couldn't get appsrc from pipeline"); - let appsrc: gst_app::AppSrc = appsrce - .dynamic_cast::() + let pipeline = gst::Pipeline::new(); + let appsrc = gst::ElementFactory::make("appsrc") + .build() + .expect("Failed to create GStreamer appsrc element") + .downcast::() .expect("couldn't cast AppSrc element at runtime!"); + appsrc.set_caps(Some(&gst_caps)); + appsrc.set_max_bytes(gst_bytes as u64); + appsrc.set_block(true); + + let sink = match device { + None => { + // no need to dither twice; use librespot dithering instead + gst::parse::bin_from_description( + "audioconvert dithering=none ! audioresample ! autoaudiosink", + true, + ) + .expect("Failed to create default GStreamer sink") + } + Some(ref x) => gst::parse::bin_from_description(x, true) + .expect("Failed to create custom GStreamer sink"), + }; + pipeline + .add(&appsrc) + .expect("Failed to add GStreamer appsrc to pipeline"); + pipeline + .add(&sink) + .expect("Failed to add GStreamer sink to pipeline"); + appsrc + .link(&sink) + .expect("Failed to link GStreamer source to sink"); + + let bus = pipeline.bus().expect("couldn't get bus from pipeline"); + let bufferpool = gst::BufferPool::new(); - let appsrc_caps = appsrc.get_caps().expect("couldn't get appsrc caps"); - let mut conf = bufferpool.get_config(); - conf.set_params(Some(&appsrc_caps), 4096 * sample_size as u32, 0, 0); + + let mut conf = bufferpool.config(); + conf.set_params(Some(&gst_caps), gst_bytes as u32, 0, 0); bufferpool .set_config(conf) .expect("couldn't configure the buffer pool"); - bufferpool - .set_active(true) - .expect("couldn't activate buffer pool"); - let (tx, rx) = sync_channel::>(64 * sample_size); - thread::spawn(move || { - for data in rx { - let buffer = bufferpool.acquire_buffer(None); - if let Ok(mut buffer) = buffer { - let mutbuf = buffer.make_mut(); - mutbuf.set_size(data.len()); - mutbuf - .copy_from_slice(0, data.as_bytes()) - .expect("Failed to copy from slice"); - let _eat = appsrc.push_buffer(buffer); + let async_error = Arc::new(Mutex::new(None)); + let async_error_clone = async_error.clone(); + + bus.set_sync_handler(move |_bus, msg| { + match msg.view() { + gst::MessageView::Eos(_) => { + println!("gst signaled end of stream"); + + let mut async_error_storage = async_error_clone + .lock() + .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG); + *async_error_storage = Some(String::from("gst signaled end of stream")); } + gst::MessageView::Error(err) => { + println!( + "Error from {:?}: {} ({:?})", + err.src().map(|s| s.path_string()), + err.error(), + err.debug() + ); + + let mut async_error_storage = async_error_clone + .lock() + .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG); + *async_error_storage = Some(format!( + "Error from {:?}: {} ({:?})", + err.src().map(|s| s.path_string()), + err.error(), + err.debug() + )); + } + _ => (), } - }); - thread::spawn(move || { - let thread_mainloop = mainloop; - let watch_mainloop = thread_mainloop.clone(); - bus.add_watch(move |_, msg| { - match msg.view() { - gst::MessageView::Eos(..) => watch_mainloop.quit(), - gst::MessageView::Error(err) => { - println!( - "Error from {:?}: {} ({:?})", - err.get_src().map(|s| s.get_path_string()), - err.get_error(), - err.get_debug() - ); - watch_mainloop.quit(); - } - _ => (), - }; - - glib::Continue(true) - }) - .expect("failed to add bus watch"); - thread_mainloop.run(); + gst::BusSyncReply::Drop }); pipeline - .set_state(gst::State::Playing) - .expect("unable to set the pipeline to the `Playing` state"); + .set_state(State::Ready) + .expect("unable to set the pipeline to the `Ready` state"); Self { - tx, + appsrc, + bufferpool, pipeline, format, + async_error, } } } impl Sink for GstreamerSink { + fn start(&mut self) -> SinkResult<()> { + *self + .async_error + .lock() + .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG) = None; + self.appsrc.send_event(FlushStop::new(true)); + self.bufferpool + .set_active(true) + .map_err(|e| SinkError::StateChange(e.to_string()))?; + self.pipeline + .set_state(State::Playing) + .map_err(|e| SinkError::StateChange(e.to_string()))?; + Ok(()) + } + + fn stop(&mut self) -> SinkResult<()> { + *self + .async_error + .lock() + .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG) = None; + self.appsrc.send_event(FlushStart::new()); + self.pipeline + .set_state(State::Paused) + .map_err(|e| SinkError::StateChange(e.to_string()))?; + self.bufferpool + .set_active(false) + .map_err(|e| SinkError::StateChange(e.to_string()))?; + Ok(()) + } + sink_as_bytes!(); } +impl Drop for GstreamerSink { + fn drop(&mut self) { + let _ = self.pipeline.set_state(State::Null); + } +} + impl SinkAsBytes for GstreamerSink { + #[inline] fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { - // Copy expensively (in to_vec()) to avoid thread synchronization - self.tx - .send(data.to_vec()) - .expect("tx send failed in write function"); + if let Some(async_error) = &*self + .async_error + .lock() + .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG) + { + return Err(SinkError::OnWrite(async_error.to_string())); + } + + let mut buffer = self + .bufferpool + .acquire_buffer(None) + .map_err(|e| SinkError::OnWrite(e.to_string()))?; + + let mutbuf = buffer.make_mut(); + mutbuf.set_size(data.len()); + mutbuf + .copy_from_slice(0, data) + .map_err(|e| SinkError::OnWrite(e.to_string()))?; + + self.appsrc + .push_buffer(buffer) + .map_err(|e| SinkError::OnWrite(e.to_string()))?; + Ok(()) } } diff --git a/playback/src/audio_backend/jackaudio.rs b/playback/src/audio_backend/jackaudio.rs index 5ba7b7ff..84b13b6f 100644 --- a/playback/src/audio_backend/jackaudio.rs +++ b/playback/src/audio_backend/jackaudio.rs @@ -1,12 +1,12 @@ use super::{Open, Sink, SinkError, SinkResult}; +use crate::NUM_CHANNELS; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::NUM_CHANNELS; use jack::{ AsyncClient, AudioOut, Client, ClientOptions, Control, Port, ProcessHandler, ProcessScope, }; -use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; +use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; pub struct JackSink { send: SyncSender, @@ -24,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); } @@ -43,7 +40,7 @@ impl ProcessHandler for JackData { impl Open for JackSink { fn open(client_name: Option, format: AudioFormat) -> Self { if format != AudioFormat::F32 { - warn!("JACK currently does not support {:?} output", format); + warn!("JACK currently does not support {format:?} output"); } info!("Using JACK sink with format {:?}", AudioFormat::F32); @@ -69,7 +66,7 @@ impl Open for JackSink { } impl Sink for JackSink { - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { + fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> { let samples = packet .samples() .map_err(|e| SinkError::OnWrite(e.to_string()))?; diff --git a/playback/src/audio_backend/mod.rs b/playback/src/audio_backend/mod.rs index b89232b7..f8f43e3f 100644 --- a/playback/src/audio_backend/mod.rs +++ b/playback/src/audio_backend/mod.rs @@ -13,6 +13,8 @@ pub enum SinkError { OnWrite(String), #[error("Audio Sink Error Invalid Parameters: {0}")] InvalidParams(String), + #[error("Audio Sink Error Changing State: {0}")] + StateChange(String), } pub type SinkResult = Result; @@ -28,7 +30,7 @@ pub trait Sink { fn stop(&mut self) -> SinkResult<()> { Ok(()) } - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()>; + fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()>; } pub type SinkBuilder = fn(Option, AudioFormat) -> Box; @@ -44,34 +46,35 @@ 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) -> SinkResult<()> { + #[inline] + fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> { use crate::convert::i24; - use zerocopy::AsBytes; + use zerocopy::IntoBytes; match packet { AudioPacket::Samples(samples) => match self.format { AudioFormat::F64 => self.write_bytes(samples.as_bytes()), AudioFormat::F32 => { - let samples_f32: &[f32] = &converter.f64_to_f32(samples); + let samples_f32: &[f32] = &converter.f64_to_f32(&samples); self.write_bytes(samples_f32.as_bytes()) } AudioFormat::S32 => { - let samples_s32: &[i32] = &converter.f64_to_s32(samples); + let samples_s32: &[i32] = &converter.f64_to_s32(&samples); self.write_bytes(samples_s32.as_bytes()) } AudioFormat::S24 => { - let samples_s24: &[i32] = &converter.f64_to_s24(samples); + let samples_s24: &[i32] = &converter.f64_to_s24(&samples); self.write_bytes(samples_s24.as_bytes()) } AudioFormat::S24_3 => { - let samples_s24_3: &[i24] = &converter.f64_to_s24_3(samples); + let samples_s24_3: &[i24] = &converter.f64_to_s24_3(&samples); self.write_bytes(samples_s24_3.as_bytes()) } AudioFormat::S16 => { - let samples_s16: &[i16] = &converter.f64_to_s16(samples); + let samples_s16: &[i16] = &converter.f64_to_s16(&samples); self.write_bytes(samples_s16.as_bytes()) } }, - AudioPacket::OggData(samples) => self.write_bytes(samples), + AudioPacket::Raw(samples) => self.write_bytes(&samples), } } }; @@ -104,7 +107,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")] @@ -124,7 +127,7 @@ pub const BACKENDS: &[(&str, SinkBuilder)] = &[ #[cfg(feature = "alsa-backend")] (AlsaSink::NAME, mk_sink::), #[cfg(feature = "portaudio-backend")] - (PortAudioSink::NAME, mk_sink::), + (PortAudioSink::NAME, mk_sink::>), #[cfg(feature = "pulseaudio-backend")] (PulseAudioSink::NAME, mk_sink::), #[cfg(feature = "jackaudio-backend")] @@ -146,11 +149,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 fd804a0e..8dfd21ea 100644 --- a/playback/src/audio_backend/pipe.rs +++ b/playback/src/audio_backend/pipe.rs @@ -2,21 +2,59 @@ use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; + use std::fs::OpenOptions; use std::io::{self, Write}; +use std::process::exit; +use thiserror::Error; + +#[derive(Debug, Error)] +enum StdoutError { + #[error(" {0}")] + OnWrite(std::io::Error), + + #[error(" File Path {file} Can Not be Opened and/or Created, {e}")] + OpenFailure { file: String, e: std::io::Error }, + + #[error(" Failed to Flush the Output Stream, {0}")] + FlushFailure(std::io::Error), + + #[error(" The Output Stream is None")] + NoOutput, +} + +impl From for SinkError { + fn from(e: StdoutError) -> SinkError { + use StdoutError::*; + let es = e.to_string(); + match e { + FlushFailure(_) | OnWrite(_) => SinkError::OnWrite(es), + OpenFailure { .. } => SinkError::ConnectionRefused(es), + NoOutput => SinkError::NotConnected(es), + } + } +} pub struct StdoutSink { output: Option>, - path: Option, + file: Option, format: AudioFormat, } impl Open for StdoutSink { - fn open(path: Option, format: AudioFormat) -> Self { - info!("Using pipe sink with format: {:?}", format); + fn open(file: Option, format: AudioFormat) -> Self { + if let Some("?") = file.as_deref() { + println!( + "\nUsage:\n\nOutput to stdout:\n\n\t--backend pipe\n\nOutput to file:\n\n\t--backend pipe --device {{filename}}\n" + ); + exit(0); + } + + info!("Using StdoutSink (pipe) with format: {format:?}"); + Self { output: None, - path, + file, format, } } @@ -24,20 +62,32 @@ impl Open for StdoutSink { impl Sink for StdoutSink { 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() + self.output.get_or_insert({ + match self.file.as_deref() { + Some(file) => Box::new( + OpenOptions::new() .write(true) - .open(path) - .map_err(|e| SinkError::ConnectionRefused(e.to_string()))?; - Box::new(open_op) - } + .create(true) + .truncate(true) + .open(file) + .map_err(|e| StdoutError::OpenFailure { + file: file.to_string(), + e, + })?, + ), None => Box::new(io::stdout()), - }; + } + }); - self.output = Some(output); - } + Ok(()) + } + + fn stop(&mut self) -> SinkResult<()> { + self.output + .take() + .ok_or(StdoutError::NoOutput)? + .flush() + .map_err(StdoutError::FlushFailure)?; Ok(()) } @@ -46,20 +96,13 @@ impl Sink for StdoutSink { } impl SinkAsBytes for StdoutSink { + #[inline] fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { - match self.output.as_deref_mut() { - Some(output) => { - 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(SinkError::NotConnected("Output is None".to_string())); - } - } + self.output + .as_deref_mut() + .ok_or(StdoutError::NoOutput)? + .write_all(data) + .map_err(StdoutError::OnWrite)?; Ok(()) } diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index 7a0b179f..f8b284f2 100644 --- a/playback/src/audio_backend/portaudio.rs +++ b/playback/src/audio_backend/portaudio.rs @@ -3,7 +3,7 @@ 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::device::{DeviceIndex, DeviceInfo, get_default_output_index}; use portaudio_rs::stream::*; use std::process::exit; use std::time::Duration; @@ -27,7 +27,7 @@ fn output_devices() -> Box> { let count = portaudio_rs::device::get_count().unwrap(); let devices = (0..count) .filter_map(|idx| portaudio_rs::device::get_info(idx).map(|info| (idx, info))) - .filter(|&(_, ref info)| info.max_output_channels > 0); + .filter(|(_, info)| info.max_output_channels > 0); Box::new(devices) } @@ -46,13 +46,13 @@ fn list_outputs() { fn find_output(device: &str) -> Option { output_devices() - .find(|&(_, ref info)| info.name == device) + .find(|(_, info)| info.name == device) .map(|(idx, _)| idx) } impl<'a> Open for PortAudioSink<'a> { fn open(device: Option, format: AudioFormat) -> PortAudioSink<'a> { - info!("Using PortAudio sink with format: {:?}", format); + info!("Using PortAudio sink with format: {format:?}"); portaudio_rs::initialize().unwrap(); @@ -88,13 +88,13 @@ impl<'a> Open for PortAudioSink<'a> { AudioFormat::S32 => open_sink!(Self::S32, i32), AudioFormat::S16 => open_sink!(Self::S16, i16), _ => { - unimplemented!("PortAudio currently does not support {:?} output", format) + unimplemented!("PortAudio currently does not support {format:?} output") } } } } -impl<'a> Sink for PortAudioSink<'a> { +impl Sink for PortAudioSink<'_> { fn start(&mut self) -> SinkResult<()> { macro_rules! start_sink { (ref mut $stream: ident, ref $parameters: ident) => {{ @@ -140,7 +140,7 @@ impl<'a> Sink for PortAudioSink<'a> { Ok(()) } - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { + 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) @@ -168,19 +168,19 @@ impl<'a> Sink for PortAudioSink<'a> { match result { Ok(_) => (), Err(portaudio_rs::PaError::OutputUnderflowed) => error!("PortAudio write underflow"), - Err(e) => panic!("PortAudio error {}", e), + Err(e) => panic!("PortAudio error {e}"), }; Ok(()) } } -impl<'a> Drop for PortAudioSink<'a> { +impl Drop for PortAudioSink<'_> { fn drop(&mut self) { portaudio_rs::terminate().unwrap(); } } -impl<'a> PortAudioSink<'a> { +impl PortAudioSink<'_> { pub const NAME: &'static str = "portaudio"; } diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs index 7487517f..0cc0850a 100644 --- a/playback/src/audio_backend/pulseaudio.rs +++ b/playback/src/audio_backend/pulseaudio.rs @@ -5,14 +5,14 @@ use crate::decoder::AudioPacket; use crate::{NUM_CHANNELS, SAMPLE_RATE}; use libpulse_binding::{self as pulse, error::PAErr, stream::Direction}; use libpulse_simple_binding::Simple; +use std::env; 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}")] + #[error( + " Unsupported Pulseaudio Sample Spec, Format {pulse_format:?} ({format:?}), Channels {channels}, Rate {rate}" + )] InvalidSampleSpec { pulse_format: pulse::sample::Format, format: AudioFormat, @@ -47,13 +47,18 @@ impl From for SinkError { } pub struct PulseAudioSink { - s: Option, + sink: Option, device: Option, + app_name: String, + stream_desc: String, format: AudioFormat, } impl Open for PulseAudioSink { fn open(device: Option, format: AudioFormat) -> Self { + let app_name = env::var("PULSE_PROP_application.name").unwrap_or_default(); + let stream_desc = env::var("PULSE_PROP_stream.description").unwrap_or_default(); + let mut actual_format = format; if actual_format == AudioFormat::F64 { @@ -61,11 +66,13 @@ impl Open for PulseAudioSink { actual_format = AudioFormat::F32; } - info!("Using PulseAudioSink with format: {:?}", actual_format); + info!("Using PulseAudioSink with format: {actual_format:?}"); Self { - s: None, + sink: None, device, + app_name, + stream_desc, format: actual_format, } } @@ -73,7 +80,7 @@ impl Open for PulseAudioSink { impl Sink for PulseAudioSink { fn start(&mut self) -> SinkResult<()> { - if self.s.is_none() { + if self.sink.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, @@ -84,13 +91,13 @@ impl Sink for PulseAudioSink { _ => unreachable!(), }; - let ss = pulse::sample::Spec { + let sample_spec = pulse::sample::Spec { format: pulse_format, channels: NUM_CHANNELS, rate: SAMPLE_RATE, }; - if !ss.is_valid() { + if !sample_spec.is_valid() { let pulse_error = PulseError::InvalidSampleSpec { pulse_format, format: self.format, @@ -101,30 +108,28 @@ impl Sink for PulseAudioSink { return Err(SinkError::from(pulse_error)); } - let s = Simple::new( + let sink = Simple::new( None, // Use the default server. - APP_NAME, // Our application's name. + &self.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. + &self.stream_desc, // Description of our stream. + &sample_spec, // Our sample format. None, // Use default channel map. None, // Use default buffering attributes. ) .map_err(PulseError::ConnectionRefused)?; - self.s = Some(s); + self.sink = Some(sink); } Ok(()) } fn stop(&mut self) -> SinkResult<()> { - let s = self.s.as_mut().ok_or(PulseError::NotConnected)?; + let sink = self.sink.take().ok_or(PulseError::NotConnected)?; - s.drain().map_err(PulseError::DrainFailure)?; - - self.s = None; + sink.drain().map_err(PulseError::DrainFailure)?; Ok(()) } @@ -132,10 +137,11 @@ impl Sink for PulseAudioSink { } impl SinkAsBytes for PulseAudioSink { + #[inline] fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { - let s = self.s.as_mut().ok_or(PulseError::NotConnected)?; + let sink = self.sink.as_mut().ok_or(PulseError::NotConnected)?; - s.write(data).map_err(PulseError::OnWrite)?; + sink.write(data).map_err(PulseError::OnWrite)?; Ok(()) } diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index 200c9fc4..b6cd3461 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -59,21 +59,32 @@ impl From for SinkError { } } +impl From for RodioError { + fn from(_: cpal::DefaultStreamConfigError) -> RodioError { + RodioError::NoDeviceAvailable + } +} + +impl From for RodioError { + fn from(_: cpal::SupportedStreamConfigsError) -> RodioError { + RodioError::NoDeviceAvailable + } +} + pub struct RodioSink { rodio_sink: rodio::Sink, - format: AudioFormat, _stream: rodio::OutputStream, } -fn list_formats(device: &rodio::Device) { +fn list_formats(device: &cpal::Device) { match device.default_output_config() { Ok(cfg) => { debug!(" Default config:"); - debug!(" {:?}", cfg); + debug!(" {cfg:?}"); } Err(e) => { // Use loglevel debug, since even the output is only debug - debug!("Error getting default rodio::Sink config: {}", e); + debug!("Error getting default rodio::Sink config: {e}"); } }; @@ -81,17 +92,17 @@ fn list_formats(device: &rodio::Device) { Ok(mut cfgs) => { if let Some(first) = cfgs.next() { debug!(" Available configs:"); - debug!(" {:?}", first); + debug!(" {first:?}"); } else { return; } for cfg in cfgs { - debug!(" {:?}", cfg); + debug!(" {cfg:?}"); } } Err(e) => { - debug!("Error getting supported rodio::Sink configs: {}", e); + debug!("Error getting supported rodio::Sink configs: {e}"); } } } @@ -117,11 +128,11 @@ fn list_outputs(host: &cpal::Host) -> Result<(), cpal::DevicesError> { match device.name() { Ok(name) if Some(&name) == default_device_name.as_ref() => (), Ok(name) => { - println!(" {}", name); + println!(" {name}"); list_formats(&device); } Err(e) => { - warn!("Cannot get device name: {}", e); + warn!("Cannot get device name: {e}"); println!(" [unknown name]"); list_formats(&device); } @@ -134,86 +145,112 @@ fn list_outputs(host: &cpal::Host) -> Result<(), cpal::DevicesError> { fn create_sink( host: &cpal::Host, device: Option, + format: AudioFormat, ) -> Result<(rodio::Sink, rodio::OutputStream), RodioError> { - let rodio_device = match device { - Some(ask) if &ask == "?" => { - let exit_code = match list_outputs(host) { - Ok(()) => 0, - Err(e) => { - error!("{}", e); - 1 - } - }; - exit(exit_code) - } + let cpal_device = match device.as_deref() { + Some("?") => match list_outputs(host) { + Ok(()) => exit(0), + Err(e) => { + error!("{e}"); + exit(1); + } + }, Some(device_name) => { + // Ignore devices for which getting name fails, or format doesn't match host.output_devices()? - .find(|d| d.name().ok().map_or(false, |name| name == device_name)) // Ignore devices for which getting name fails - .ok_or(RodioError::DeviceNotAvailable(device_name))? + .find(|d| d.name().ok().is_some_and(|name| name == device_name)) // Ignore devices for which getting name fails + .ok_or_else(|| RodioError::DeviceNotAvailable(device_name.to_string()))? } None => host .default_output_device() .ok_or(RodioError::NoDeviceAvailable)?, }; - let name = rodio_device.name().ok(); + let name = cpal_device.name().ok(); info!( "Using audio device: {}", name.as_deref().unwrap_or("[unknown name]") ); - let (stream, handle) = rodio::OutputStream::try_from_device(&rodio_device)?; - let sink = rodio::Sink::try_new(&handle)?; + // First try native stereo 44.1 kHz playback, then fall back to the device default sample rate + // (some devices only support 48 kHz and Rodio will resample linearly), then fall back to + // whatever the default device config is (like mono). + let default_config = cpal_device.default_output_config()?; + let config = cpal_device + .supported_output_configs()? + .find(|c| c.channels() == NUM_CHANNELS as cpal::ChannelCount) + .and_then(|c| { + c.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE)) + .or_else(|| c.try_with_sample_rate(default_config.sample_rate())) + }) + .unwrap_or(default_config); + + let sample_format = match format { + AudioFormat::F64 => cpal::SampleFormat::F64, + AudioFormat::F32 => cpal::SampleFormat::F32, + AudioFormat::S32 => cpal::SampleFormat::I32, + AudioFormat::S24 | AudioFormat::S24_3 => cpal::SampleFormat::I24, + AudioFormat::S16 => cpal::SampleFormat::I16, + }; + + let mut stream = match rodio::OutputStreamBuilder::default() + .with_device(cpal_device.clone()) + .with_config(&config.config()) + .with_sample_format(sample_format) + .open_stream() + { + Ok(exact_stream) => exact_stream, + Err(e) => { + warn!("unable to create Rodio output, falling back to default: {e}"); + rodio::OutputStreamBuilder::from_device(cpal_device)?.open_stream_or_fallback()? + } + }; + + // disable logging on stream drop + stream.log_on_drop(false); + + let sink = rodio::Sink::connect_new(stream.mixer()); Ok((sink, stream)) } pub fn open(host: cpal::Host, device: Option, format: AudioFormat) -> RodioSink { info!( - "Using Rodio sink with format {:?} and cpal host: {}", - format, + "Using Rodio sink with format {format:?} and cpal host: {}", host.id().name() ); - if format != AudioFormat::S16 && format != AudioFormat::F32 { - unimplemented!("Rodio currently only supports F32 and S16 formats"); - } - - let (sink, stream) = create_sink(&host, device).unwrap(); + let (sink, stream) = create_sink(&host, device, format).unwrap(); debug!("Rodio sink was created"); RodioSink { rodio_sink: sink, - format, _stream: stream, } } impl Sink for RodioSink { - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { + fn start(&mut self) -> SinkResult<()> { + self.rodio_sink.play(); + Ok(()) + } + + fn stop(&mut self) -> SinkResult<()> { + self.rodio_sink.sleep_until_end(); + self.rodio_sink.pause(); + Ok(()) + } + + fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> { let samples = packet .samples() .map_err(|e| RodioError::Samples(e.to_string()))?; - match self.format { - AudioFormat::F32 => { - let samples_f32: &[f32] = &converter.f64_to_f32(samples); - let source = rodio::buffer::SamplesBuffer::new( - NUM_CHANNELS as u16, - SAMPLE_RATE, - samples_f32, - ); - self.rodio_sink.append(source); - } - AudioFormat::S16 => { - let samples_s16: &[i16] = &converter.f64_to_s16(samples); - let source = rodio::buffer::SamplesBuffer::new( - NUM_CHANNELS as u16, - SAMPLE_RATE, - samples_s16, - ); - self.rodio_sink.append(source); - } - _ => unreachable!(), - }; + let samples_f32: &[f32] = &converter.f64_to_f32(samples); + let source = rodio::buffer::SamplesBuffer::new( + NUM_CHANNELS as cpal::ChannelCount, + SAMPLE_RATE, + samples_f32, + ); + self.rodio_sink.append(source); // Chunk sizes seem to be about 256 to 3000 ish items long. // Assuming they're on average 1628 then a half second buffer is: @@ -227,5 +264,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 6272fa32..0d220928 100644 --- a/playback/src/audio_backend/sdl.rs +++ b/playback/src/audio_backend/sdl.rs @@ -45,7 +45,7 @@ impl Open for SdlSink { AudioFormat::S32 => open_sink!(Self::S32, i32), AudioFormat::S16 => open_sink!(Self::S16, i16), _ => { - unimplemented!("SDL currently does not support {:?} output", format) + unimplemented!("SDL currently does not support {format:?} output") } } } @@ -82,7 +82,7 @@ impl Sink for SdlSink { Ok(()) } - fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> { + 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 @@ -95,24 +95,24 @@ impl Sink for SdlSink { let samples = packet .samples() .map_err(|e| SinkError::OnWrite(e.to_string()))?; - match self { + let result = match self { Self::F32(queue) => { let samples_f32: &[f32] = &converter.f64_to_f32(samples); drain_sink!(queue, AudioFormat::F32.size()); - queue.queue(samples_f32) + queue.queue_audio(samples_f32) } Self::S32(queue) => { let samples_s32: &[i32] = &converter.f64_to_s32(samples); drain_sink!(queue, AudioFormat::S32.size()); - queue.queue(samples_s32) + queue.queue_audio(samples_s32) } Self::S16(queue) => { let samples_s16: &[i16] = &converter.f64_to_s16(samples); drain_sink!(queue, AudioFormat::S16.size()); - queue.queue(samples_s16) + queue.queue_audio(samples_s16) } }; - Ok(()) + result.map_err(SinkError::OnWrite) } } diff --git a/playback/src/audio_backend/subprocess.rs b/playback/src/audio_backend/subprocess.rs index c501cf83..c624718b 100644 --- a/playback/src/audio_backend/subprocess.rs +++ b/playback/src/audio_backend/subprocess.rs @@ -4,76 +4,204 @@ use crate::convert::Converter; use crate::decoder::AudioPacket; use shell_words::split; -use std::io::Write; -use std::process::{Child, Command, Stdio}; +use std::io::{ErrorKind, Write}; +use std::process::{Child, Command, Stdio, exit}; +use thiserror::Error; + +#[derive(Debug, Error)] +enum SubprocessError { + #[error(" {0}")] + OnWrite(std::io::Error), + + #[error(" Command {command} Can Not be Executed, {e}")] + SpawnFailure { command: String, e: std::io::Error }, + + #[error(" Failed to Parse Command args for {command}, {e}")] + InvalidArgs { + command: String, + e: shell_words::ParseError, + }, + + #[error(" Failed to Flush the Subprocess, {0}")] + FlushFailure(std::io::Error), + + #[error(" Failed to Kill the Subprocess, {0}")] + KillFailure(std::io::Error), + + #[error(" Failed to Wait for the Subprocess to Exit, {0}")] + WaitFailure(std::io::Error), + + #[error(" The Subprocess is no longer able to accept Bytes")] + WriteZero, + + #[error(" Missing Required Shell Command")] + MissingCommand, + + #[error(" The Subprocess is None")] + NoChild, + + #[error(" The Subprocess's stdin is None")] + NoStdin, +} + +impl From for SinkError { + fn from(e: SubprocessError) -> SinkError { + use SubprocessError::*; + let es = e.to_string(); + match e { + FlushFailure(_) | KillFailure(_) | WaitFailure(_) | OnWrite(_) | WriteZero => { + SinkError::OnWrite(es) + } + SpawnFailure { .. } => SinkError::ConnectionRefused(es), + MissingCommand | InvalidArgs { .. } => SinkError::InvalidParams(es), + NoChild | NoStdin => SinkError::NotConnected(es), + } + } +} pub struct SubprocessSink { - shell_command: String, + shell_command: Option, child: Option, format: AudioFormat, } impl Open for SubprocessSink { fn open(shell_command: Option, format: AudioFormat) -> Self { - info!("Using subprocess sink with format: {:?}", format); + if let Some("?") = shell_command.as_deref() { + println!( + "\nUsage:\n\nOutput to a Subprocess:\n\n\t--backend subprocess --device {{shell_command}}\n" + ); + exit(0); + } - if let Some(shell_command) = shell_command { - SubprocessSink { - shell_command, - child: None, - format, - } - } else { - panic!("subprocess sink requires specifying a shell command"); + info!("Using SubprocessSink with format: {format:?}"); + + Self { + shell_command, + child: None, + format, } } } impl Sink for SubprocessSink { fn start(&mut self) -> SinkResult<()> { - let args = split(&self.shell_command).unwrap(); - 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); + self.child.get_or_insert({ + match self.shell_command.as_deref() { + Some(command) => { + let args = split(command).map_err(|e| SubprocessError::InvalidArgs { + command: command.to_string(), + e, + })?; + + Command::new(&args[0]) + .args(&args[1..]) + .stdin(Stdio::piped()) + .spawn() + .map_err(|e| SubprocessError::SpawnFailure { + command: command.to_string(), + e, + })? + } + None => return Err(SubprocessError::MissingCommand.into()), + } + }); + Ok(()) } fn stop(&mut self) -> SinkResult<()> { - if let Some(child) = &mut self.child.take() { - child - .kill() - .map_err(|e| SinkError::OnWrite(e.to_string()))?; - child - .wait() - .map_err(|e| SinkError::OnWrite(e.to_string()))?; + let child = &mut self.child.take().ok_or(SubprocessError::NoChild)?; + + match child.try_wait() { + // The process has already exited + // nothing to do. + Ok(Some(_)) => Ok(()), + Ok(_) => { + // The process Must DIE!!! + child + .stdin + .take() + .ok_or(SubprocessError::NoStdin)? + .flush() + .map_err(SubprocessError::FlushFailure)?; + + child.kill().map_err(SubprocessError::KillFailure)?; + child.wait().map_err(SubprocessError::WaitFailure)?; + + Ok(()) + } + Err(e) => Err(SubprocessError::WaitFailure(e).into()), } - Ok(()) } sink_as_bytes!(); } impl SinkAsBytes for SubprocessSink { + #[inline] fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { - if let Some(child) = &mut self.child { - let child_stdin = child + // We get one attempted restart per write. + // We don't want to get stuck in a restart loop. + let mut restarted = false; + let mut start_index = 0; + let data_len = data.len(); + let mut end_index = data_len; + + loop { + match self + .child + .as_ref() + .ok_or(SubprocessError::NoChild)? .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()))?; + .as_ref() + .ok_or(SubprocessError::NoStdin)? + .write(&data[start_index..end_index]) + { + Ok(0) => { + // Potentially fatal. + // As per the docs a return value of 0 + // means we shouldn't try to write to the + // process anymore so let's try a restart + // if we haven't already. + self.try_restart(SubprocessError::WriteZero, &mut restarted)?; + + continue; + } + Ok(bytes_written) => { + // What we want, a successful write. + start_index = data_len.min(start_index + bytes_written); + end_index = data_len.min(start_index + bytes_written); + + if end_index == data_len { + break Ok(()); + } + } + // Non-fatal, retry the write. + Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => { + // Very possibly fatal, + // but let's try a restart anyway if we haven't already. + self.try_restart(SubprocessError::OnWrite(e), &mut restarted)?; + + continue; + } + } } - Ok(()) } } impl SubprocessSink { pub const NAME: &'static str = "subprocess"; + + fn try_restart(&mut self, e: SubprocessError, restarted: &mut bool) -> SinkResult<()> { + // If the restart fails throw the original error back. + if !*restarted && self.stop().is_ok() && self.start().is_ok() { + *restarted = true; + + Ok(()) + } else { + Err(e.into()) + } + } } diff --git a/playback/src/config.rs b/playback/src/config.rs index c442faee..a747ce38 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -1,10 +1,7 @@ -use super::player::db_to_ratio; -use crate::convert::i24; -pub use crate::dither::{mk_ditherer, DithererBuilder, TriangularDitherer}; +use std::{mem, str::FromStr, time::Duration}; -use std::mem; -use std::str::FromStr; -use std::time::Duration; +pub use crate::dither::{DithererBuilder, TriangularDitherer, mk_ditherer}; +use crate::{convert::i24, player::duration_to_coefficient}; #[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] pub enum Bitrate { @@ -76,7 +73,7 @@ impl AudioFormat { } } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NormalisationType { Album, Track, @@ -101,7 +98,7 @@ impl Default for NormalisationType { } } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NormalisationMethod { Basic, Dynamic, @@ -133,15 +130,18 @@ pub struct PlayerConfig { pub normalisation: bool, pub normalisation_type: NormalisationType, pub normalisation_method: NormalisationMethod, - pub normalisation_pregain: f64, - pub normalisation_threshold: f64, - pub normalisation_attack: Duration, - pub normalisation_release: Duration, - pub normalisation_knee: f64, + pub normalisation_pregain_db: f64, + pub normalisation_threshold_dbfs: f64, + pub normalisation_attack_cf: f64, + pub normalisation_release_cf: f64, + pub normalisation_knee_db: f64, // pass function pointers so they can be lazily instantiated *after* spawning a thread // (thereby circumventing Send bounds that they might not satisfy) pub ditherer: Option, + /// Setting this will enable periodically sending events during playback informing about the playback position + /// To consume the PlayerEvent::PositionChanged event, listen to events via `Player::get_player_event_channel()`` + pub position_update_interval: Option, } impl Default for PlayerConfig { @@ -152,13 +152,14 @@ impl Default for PlayerConfig { normalisation: false, normalisation_type: NormalisationType::default(), normalisation_method: NormalisationMethod::default(), - normalisation_pregain: 0.0, - normalisation_threshold: db_to_ratio(-2.0), - normalisation_attack: Duration::from_millis(5), - normalisation_release: Duration::from_millis(100), - normalisation_knee: 1.0, + normalisation_pregain_db: 0.0, + normalisation_threshold_dbfs: -2.0, + normalisation_attack_cf: duration_to_coefficient(Duration::from_millis(5)), + normalisation_release_cf: duration_to_coefficient(Duration::from_millis(100)), + normalisation_knee_db: 5.0, passthrough: false, ditherer: Some(mk_ditherer::), + position_update_interval: None, } } } diff --git a/playback/src/convert.rs b/playback/src/convert.rs index 962ade66..31e710da 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -1,7 +1,7 @@ use crate::dither::{Ditherer, DithererBuilder}; -use zerocopy::AsBytes; +use zerocopy::{Immutable, IntoBytes}; -#[derive(AsBytes, Copy, Clone, Debug)] +#[derive(Immutable, IntoBytes, Copy, Clone, Debug)] #[allow(non_camel_case_types)] #[repr(transparent)] pub struct i24([u8; 3]); @@ -23,105 +23,111 @@ pub struct Converter { impl Converter { pub fn new(dither_config: Option) -> Self { - if let Some(ref ditherer_builder) = dither_config { - let ditherer = (ditherer_builder)(); - info!("Converting with ditherer: {}", ditherer.name()); - Self { - ditherer: Some(ditherer), + match dither_config { + Some(ditherer_builder) => { + let ditherer = (ditherer_builder)(); + info!("Converting with ditherer: {}", ditherer.name()); + Self { + ditherer: Some(ditherer), + } } - } else { - Self { ditherer: None } + None => Self { ditherer: None }, } } - /// To convert PCM samples from floating point normalized as `-1.0..=1.0` - /// to 32-bit signed integer, multiply by 2147483648 (0x80000000) and - /// saturate at the bounds of `i32`. - const SCALE_S32: f64 = 2147483648.; + /// Base bit positions for PCM format scaling. These represent the position + /// of the most significant bit in each format's full-scale representation. + /// For signed integers in two's complement, full scale is 2^(bits-1). + const SHIFT_S16: u8 = 15; // 16-bit: 2^15 = 32768 + const SHIFT_S24: u8 = 23; // 24-bit: 2^23 = 8388608 + const SHIFT_S32: u8 = 31; // 32-bit: 2^31 = 2147483648 - /// To convert PCM samples from floating point normalized as `-1.0..=1.0` - /// to 24-bit signed integer, multiply by 8388608 (0x800000) and saturate - /// at the bounds of `i24`. - const SCALE_S24: f64 = 8388608.; + /// Additional bit shifts needed to scale from 16-bit to higher bit depths. + /// These are the differences between the base shift amounts above. + const SHIFT_16_TO_24: u8 = Self::SHIFT_S24 - Self::SHIFT_S16; // 23 - 15 = 8 + const SHIFT_16_TO_32: u8 = Self::SHIFT_S32 - Self::SHIFT_S16; // 31 - 15 = 16 - /// To convert PCM samples from floating point normalized as `-1.0..=1.0` - /// to 16-bit signed integer, multiply by 32768 (0x8000) and saturate at - /// the bounds of `i16`. When the samples were encoded using the same - /// scaling factor, like the reference Vorbis encoder does, this makes - /// conversions transparent. - const SCALE_S16: f64 = 32768.; + /// Pre-calculated scale factor for 24-bit clamping bounds + const SCALE_S24: f64 = (1_u64 << Self::SHIFT_S24) as f64; - pub fn scale(&mut self, sample: f64, factor: f64) -> f64 { - let dither = match self.ditherer { - Some(ref mut d) => d.noise(), - None => 0.0, - }; - - // From the many float to int conversion methods available, match what - // the reference Vorbis implementation uses: sample * 32768 (for 16 bit) - let int_value = sample * factor + dither; - - // Casting float to integer rounds towards zero by default, i.e. it - // truncates, and that generates larger error than rounding to nearest. - int_value.round() + /// Scale audio samples with optimal dithering strategy for Spotify's 16-bit source material. + /// + /// Since Spotify audio is always 16-bit depth, this function: + /// 1. When dithering: applies noise at 16-bit level, preserves fractional precision, + /// then scales to target format and rounds once at the end + /// 2. When not dithering: scales directly from normalized float to target format + /// + /// The `shift` parameter specifies how many extra bits to shift beyond + /// the base 16-bit scaling (0 for 16-bit, 8 for 24-bit, 16 for 32-bit). + #[inline] + pub fn scale(&mut self, sample: f64, shift: u8) -> f64 { + match self.ditherer.as_mut() { + Some(d) => { + // With dithering: Apply noise at 16-bit level to address original quantization, + // then scale up to target format while preserving sub-LSB information + let dithered_16bit = sample * (1_u64 << Self::SHIFT_S16) as f64 + d.noise(); + let scaled = dithered_16bit * (1_u64 << shift) as f64; + scaled.round() + } + None => { + // No dithering: Scale directly from normalized float to target format + // using a single bit shift operation (base 16-bit shift + additional shift) + let total_shift = Self::SHIFT_S16 + shift; + (sample * (1_u64 << total_shift) as f64).round() + } + } } - // Special case for samples packed in a word of greater bit depth (e.g. - // S24): clamp between min and max to ensure that the most significant - // byte is zero. Otherwise, dithering may cause an overflow. This is not - // necessary for other formats, because casting to integer will saturate - // to the bounds of the primitive. - pub fn clamping_scale(&mut self, sample: f64, factor: f64) -> f64 { - let int_value = self.scale(sample, factor); + /// Clamping scale specifically for 24-bit output to prevent MSB overflow. + /// Only used for S24 formats where samples are packed in 32-bit words. + /// Ensures the most significant byte is zero to prevent overflow during dithering. + #[inline] + pub fn clamping_scale_s24(&mut self, sample: f64) -> f64 { + let int_value = self.scale(sample, Self::SHIFT_16_TO_24); // In two's complement, there are more negative than positive values. - let min = -factor; - let max = factor - 1.0; + let min = -Self::SCALE_S24; + let max = Self::SCALE_S24 - 1.0; - if int_value < min { - return min; - } else if int_value > max { - return max; - } - int_value + int_value.clamp(min, max) } + #[inline] pub fn f64_to_f32(&mut self, samples: &[f64]) -> Vec { samples.iter().map(|sample| *sample as f32).collect() } + #[inline] pub fn f64_to_s32(&mut self, samples: &[f64]) -> Vec { samples .iter() - .map(|sample| self.scale(*sample, Self::SCALE_S32) as i32) + .map(|sample| self.scale(*sample, Self::SHIFT_16_TO_32) as i32) .collect() } - // S24 is 24-bit PCM packed in an upper 32-bit word + /// S24 is 24-bit PCM packed in an upper 32-bit word + #[inline] pub fn f64_to_s24(&mut self, samples: &[f64]) -> Vec { samples .iter() - .map(|sample| self.clamping_scale(*sample, Self::SCALE_S24) as i32) + .map(|sample| self.clamping_scale_s24(*sample) as i32) .collect() } - // S24_3 is 24-bit PCM in a 3-byte array + /// S24_3 is 24-bit PCM in a 3-byte array + #[inline] pub fn f64_to_s24_3(&mut self, samples: &[f64]) -> Vec { samples .iter() - .map(|sample| { - // Not as DRY as calling f32_to_s24 first, but this saves iterating - // over all samples twice. - let int_value = self.clamping_scale(*sample, Self::SCALE_S24) as i32; - i24::from_s24(int_value) - }) + .map(|sample| i24::from_s24(self.clamping_scale_s24(*sample) as i32)) .collect() } + #[inline] pub fn f64_to_s16(&mut self, samples: &[f64]) -> Vec { samples .iter() - .map(|sample| self.scale(*sample, Self::SCALE_S16) as i16) + .map(|sample| self.scale(*sample, 0) as i16) .collect() } } diff --git a/playback/src/decoder/lewton_decoder.rs b/playback/src/decoder/lewton_decoder.rs deleted file mode 100644 index bc90b992..00000000 --- a/playback/src/decoder/lewton_decoder.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult}; - -use lewton::audio::AudioReadError::AudioIsHeader; -use lewton::inside_ogg::OggStreamReader; -use lewton::samples::InterleavedSamples; -use lewton::OggReadError::NoCapturePatternFound; -use lewton::VorbisError::{BadAudio, OggError}; - -use std::io::{Read, Seek}; - -pub struct VorbisDecoder(OggStreamReader); - -impl VorbisDecoder -where - R: Read + Seek, -{ - pub fn new(input: R) -> DecoderResult> { - let reader = - OggStreamReader::new(input).map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?; - Ok(VorbisDecoder(reader)) - } -} - -impl AudioDecoder for VorbisDecoder -where - R: Read + Seek, -{ - fn seek(&mut self, absgp: u64) -> DecoderResult<()> { - self.0 - .seek_absgp_pg(absgp) - .map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?; - Ok(()) - } - - fn next_packet(&mut self) -> DecoderResult> { - loop { - match self.0.read_dec_packet_generic::>() { - Ok(Some(packet)) => return Ok(Some(AudioPacket::samples_from_f32(packet.samples))), - Ok(None) => return Ok(None), - Err(BadAudio(AudioIsHeader)) => (), - Err(OggError(NoCapturePatternFound)) => (), - Err(e) => return Err(DecoderError::LewtonDecoder(e.to_string())), - } - } - } -} diff --git a/playback/src/decoder/mod.rs b/playback/src/decoder/mod.rs index 087bba4c..e77d6e9e 100644 --- a/playback/src/decoder/mod.rs +++ b/playback/src/decoder/mod.rs @@ -1,26 +1,30 @@ +use std::ops::Deref; + use thiserror::Error; -mod lewton_decoder; -pub use lewton_decoder::VorbisDecoder; - +#[cfg(feature = "passthrough-decoder")] mod passthrough_decoder; +#[cfg(feature = "passthrough-decoder")] pub use passthrough_decoder::PassthroughDecoder; +mod symphonia_decoder; +pub use symphonia_decoder::SymphoniaDecoder; + #[derive(Error, Debug)] pub enum DecoderError { - #[error("Lewton Decoder Error: {0}")] - LewtonDecoder(String), #[error("Passthrough Decoder Error: {0}")] PassthroughDecoder(String), + #[error("Symphonia Decoder Error: {0}")] + SymphoniaDecoder(String), } pub type DecoderResult = Result; #[derive(Error, Debug)] pub enum AudioPacketError { - #[error("Decoder OggData Error: Can't return OggData on Samples")] - OggData, - #[error("Decoder Samples Error: Can't return Samples on OggData")] + #[error("Decoder Raw Error: Can't return Raw on Samples")] + Raw, + #[error("Decoder Samples Error: Can't return Samples on Raw")] Samples, } @@ -28,38 +32,61 @@ pub type AudioPacketResult = Result; pub enum AudioPacket { Samples(Vec), - OggData(Vec), + Raw(Vec), } impl AudioPacket { - pub fn samples_from_f32(f32_samples: Vec) -> Self { - let f64_samples = f32_samples.iter().map(|sample| *sample as f64).collect(); - AudioPacket::Samples(f64_samples) - } - + #[inline] pub fn samples(&self) -> AudioPacketResult<&[f64]> { match self { AudioPacket::Samples(s) => Ok(s), - AudioPacket::OggData(_) => Err(AudioPacketError::OggData), + AudioPacket::Raw(_) => Err(AudioPacketError::Raw), } } - pub fn oggdata(&self) -> AudioPacketResult<&[u8]> { + #[inline] + pub fn raw(&self) -> AudioPacketResult<&[u8]> { match self { - AudioPacket::OggData(d) => Ok(d), + AudioPacket::Raw(d) => Ok(d), AudioPacket::Samples(_) => Err(AudioPacketError::Samples), } } + #[inline] pub fn is_empty(&self) -> bool { match self { AudioPacket::Samples(s) => s.is_empty(), - AudioPacket::OggData(d) => d.is_empty(), + AudioPacket::Raw(d) => d.is_empty(), } } } -pub trait AudioDecoder { - fn seek(&mut self, absgp: u64) -> DecoderResult<()>; - fn next_packet(&mut self) -> DecoderResult>; +#[derive(Debug, Clone)] +pub struct AudioPacketPosition { + pub position_ms: u32, + pub skipped: bool, +} + +impl Deref for AudioPacketPosition { + type Target = u32; + fn deref(&self) -> &Self::Target { + &self.position_ms + } +} + +pub trait AudioDecoder { + fn seek(&mut self, position_ms: u32) -> Result; + fn next_packet(&mut self) -> DecoderResult>; +} + +impl From for librespot_core::error::Error { + fn from(err: DecoderError) -> Self { + librespot_core::error::Error::aborted(err) + } +} + +impl From for DecoderError { + fn from(err: symphonia::core::errors::Error) -> Self { + Self::SymphoniaDecoder(err.to_string()) + } } diff --git a/playback/src/decoder/passthrough_decoder.rs b/playback/src/decoder/passthrough_decoder.rs index dd8e3b32..ec05f711 100644 --- a/playback/src/decoder/passthrough_decoder.rs +++ b/playback/src/decoder/passthrough_decoder.rs @@ -1,10 +1,20 @@ // Passthrough decoder for librespot -use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult}; -use ogg::{OggReadError, Packet, PacketReader, PacketWriteEndInfo, PacketWriter}; -use std::io::{Read, Seek}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + io::{Read, Seek}, + time::{SystemTime, UNIX_EPOCH}, +}; -fn get_header(code: u8, rdr: &mut PacketReader) -> DecoderResult> +// TODO: move this to the Symphonia Ogg demuxer +use ogg::{OggReadError, Packet, PacketReader, PacketWriteEndInfo, PacketWriter}; + +use super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult}; + +use crate::{ + MS_PER_PAGE, PAGES_PER_MS, + metadata::audio::{AudioFileFormat, AudioFiles}, +}; + +fn get_header(code: u8, rdr: &mut PacketReader) -> DecoderResult> where T: Read + Seek, { @@ -16,34 +26,40 @@ where debug!("Vorbis header type {}", &pkt_type); if pkt_type != code { - return Err(DecoderError::PassthroughDecoder("Invalid Data".to_string())); + return Err(DecoderError::PassthroughDecoder("Invalid Data".into())); } - Ok(pck.data.into_boxed_slice()) + Ok(pck.data) } pub struct PassthroughDecoder { rdr: PacketReader, - wtr: PacketWriter>, + wtr: PacketWriter<'static, Vec>, eos: bool, bos: bool, ofsgp_page: u64, stream_serial: u32, - ident: Box<[u8]>, - comment: Box<[u8]>, - setup: Box<[u8]>, + ident: Vec, + comment: Vec, + setup: Vec, } impl PassthroughDecoder { /// Constructs a new Decoder from a given implementation of `Read + Seek`. - pub fn new(rdr: R) -> DecoderResult { + pub fn new(rdr: R, format: AudioFileFormat) -> DecoderResult { + if !AudioFiles::is_ogg_vorbis(format) { + return Err(DecoderError::PassthroughDecoder(format!( + "Passthrough decoder is not implemented for format {format:?}" + ))); + } + let mut rdr = PacketReader::new(rdr); let since_epoch = SystemTime::now() .duration_since(UNIX_EPOCH) .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); + info!("Starting passthrough track with serial {stream_serial}"); // search for ident, comment, setup let ident = get_header(1, &mut rdr)?; @@ -65,10 +81,16 @@ impl PassthroughDecoder { bos: false, }) } + + fn position_pcm_to_ms(position_pcm: u64) -> u32 { + (position_pcm as f64 * MS_PER_PAGE) as u32 + } } impl AudioDecoder for PassthroughDecoder { - fn seek(&mut self, absgp: u64) -> DecoderResult<()> { + fn seek(&mut self, position_ms: u32) -> Result { + let absgp = (position_ms as f64 * PAGES_PER_MS) as u64; + // add an eos to previous stream if missing if self.bos && !self.eos { match self.rdr.read_packet() { @@ -76,7 +98,7 @@ impl AudioDecoder for PassthroughDecoder { let absgp_page = pck.absgp_page() - self.ofsgp_page; self.wtr .write_packet( - pck.data.into_boxed_slice(), + pck.data, self.stream_serial, PacketWriteEndInfo::EndStream, absgp_page, @@ -101,20 +123,20 @@ impl AudioDecoder for PassthroughDecoder { .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?; match pck { Some(pck) => { - self.ofsgp_page = pck.absgp_page(); - debug!("Seek to offset page {}", self.ofsgp_page); - Ok(()) + let new_page = pck.absgp_page(); + self.ofsgp_page = new_page; + debug!("Seek to offset page {}", new_page); + let new_position_ms = Self::position_pcm_to_ms(new_page); + Ok(new_position_ms) } - None => Err(DecoderError::PassthroughDecoder( - "Packet is None".to_string(), - )), + None => Err(DecoderError::PassthroughDecoder("Packet is None".into())), } } Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())), } } - fn next_packet(&mut self) -> DecoderResult> { + fn next_packet(&mut self) -> DecoderResult> { // write headers if we are (re)starting if !self.bos { self.wtr @@ -174,7 +196,7 @@ impl AudioDecoder for PassthroughDecoder { self.wtr .write_packet( - pck.data.into_boxed_slice(), + pck.data, self.stream_serial, inf, pckgp_page - self.ofsgp_page, @@ -184,8 +206,15 @@ impl AudioDecoder for PassthroughDecoder { let data = self.wtr.inner_mut(); if !data.is_empty() { - let ogg_data = AudioPacket::OggData(std::mem::take(data)); - return Ok(Some(ogg_data)); + let position_ms = Self::position_pcm_to_ms(pckgp_page); + let packet_position = AudioPacketPosition { + position_ms, + skipped: false, + }; + + let ogg_data = AudioPacket::Raw(std::mem::take(data)); + + return Ok(Some((packet_position, ogg_data))); } } } diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs new file mode 100644 index 00000000..63d49d0f --- /dev/null +++ b/playback/src/decoder/symphonia_decoder.rs @@ -0,0 +1,226 @@ +use std::{io, time::Duration}; + +use symphonia::{ + core::{ + audio::SampleBuffer, + codecs::{Decoder, DecoderOptions}, + errors::Error, + formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, + io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, + meta::{StandardTagKey, Value}, + }, + default::{ + codecs::{MpaDecoder, VorbisDecoder}, + formats::{MpaReader, OggReader}, + }, +}; + +use super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult}; + +use crate::{ + NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, + metadata::audio::{AudioFileFormat, AudioFiles}, + player::NormalisationData, +}; + +pub struct SymphoniaDecoder { + format: Box, + decoder: Box, + sample_buffer: Option>, +} + +impl SymphoniaDecoder { + pub fn new(input: R, file_format: AudioFileFormat) -> DecoderResult + where + R: MediaSource + 'static, + { + let mss_opts = MediaSourceStreamOptions { + buffer_len: librespot_audio::AudioFetchParams::get().minimum_download_size, + }; + let mss = MediaSourceStream::new(Box::new(input), mss_opts); + + let format_opts = FormatOptions { + enable_gapless: true, + ..Default::default() + }; + + let format: Box = if AudioFiles::is_ogg_vorbis(file_format) { + Box::new(OggReader::try_new(mss, &format_opts)?) + } else if AudioFiles::is_mp3(file_format) { + Box::new(MpaReader::try_new(mss, &format_opts)?) + } else { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported format: {file_format:?}" + ))); + }; + + let track = format.default_track().ok_or_else(|| { + DecoderError::SymphoniaDecoder("Could not retrieve default track".into()) + })?; + + let decoder_opts: DecoderOptions = Default::default(); + let decoder: Box = if AudioFiles::is_ogg_vorbis(file_format) { + Box::new(VorbisDecoder::try_new(&track.codec_params, &decoder_opts)?) + } else if AudioFiles::is_mp3(file_format) { + Box::new(MpaDecoder::try_new(&track.codec_params, &decoder_opts)?) + } else { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported decoder: {file_format:?}" + ))); + }; + + let rate = decoder.codec_params().sample_rate.ok_or_else(|| { + DecoderError::SymphoniaDecoder("Could not retrieve sample rate".into()) + })?; + if rate != SAMPLE_RATE { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported sample rate: {rate}" + ))); + } + + let channels = decoder.codec_params().channels.ok_or_else(|| { + DecoderError::SymphoniaDecoder("Could not retrieve channel configuration".into()) + })?; + if channels.count() != NUM_CHANNELS as usize { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported number of channels: {channels}" + ))); + } + + Ok(Self { + format, + decoder, + + // We set the sample buffer when decoding the first full packet, + // whose duration is also the ideal sample buffer size. + sample_buffer: None, + }) + } + + pub fn normalisation_data(&mut self) -> Option { + let mut metadata = self.format.metadata(); + + // Advance to the latest metadata revision. + // None means we hit the latest. + loop { + if metadata.pop().is_none() { + break; + } + } + + let tags = metadata.current()?.tags(); + + if tags.is_empty() { + None + } else { + let mut data = NormalisationData::default(); + + for tag in tags { + if let Value::Float(value) = tag.value { + match tag.std_key { + Some(StandardTagKey::ReplayGainAlbumGain) => data.album_gain_db = value, + Some(StandardTagKey::ReplayGainAlbumPeak) => data.album_peak = value, + Some(StandardTagKey::ReplayGainTrackGain) => data.track_gain_db = value, + Some(StandardTagKey::ReplayGainTrackPeak) => data.track_peak = value, + _ => (), + } + } + } + + Some(data) + } + } + + #[inline] + fn ts_to_ms(&self, ts: u64) -> u32 { + match self.decoder.codec_params().time_base { + Some(time_base) => { + let time = Duration::from(time_base.calc_time(ts)); + time.as_millis() as u32 + } + // Fallback in the unexpected case that the format has no base time set. + None => (ts as f64 * PAGES_PER_MS) as u32, + } + } +} + +impl AudioDecoder for SymphoniaDecoder { + fn seek(&mut self, position_ms: u32) -> Result { + // "Saturate" the position_ms to the duration of the track if it exceeds it. + let mut target = Duration::from_millis(position_ms.into()); + let codec_params = self.decoder.codec_params(); + if let (Some(time_base), Some(n_frames)) = (codec_params.time_base, codec_params.n_frames) { + let duration = Duration::from(time_base.calc_time(n_frames)); + if target > duration { + target = duration; + } + } + + // `track_id: None` implies the default track ID (of the container, not of Spotify). + let seeked_to_ts = self.format.seek( + SeekMode::Accurate, + SeekTo::Time { + time: target.into(), + track_id: None, + }, + )?; + + // Seeking is a `FormatReader` operation, so the decoder cannot reliably + // know when a seek took place. Reset it to avoid audio glitches. + self.decoder.reset(); + + Ok(self.ts_to_ms(seeked_to_ts.actual_ts)) + } + + fn next_packet(&mut self) -> DecoderResult> { + let mut skipped = false; + + loop { + let packet = match self.format.next_packet() { + Ok(packet) => packet, + Err(Error::IoError(err)) => { + if err.kind() == io::ErrorKind::UnexpectedEof { + return Ok(None); + } else { + return Err(DecoderError::SymphoniaDecoder(err.to_string())); + } + } + Err(err) => { + return Err(err.into()); + } + }; + + let position_ms = self.ts_to_ms(packet.ts()); + let packet_position = AudioPacketPosition { + position_ms, + skipped, + }; + + match self.decoder.decode(&packet) { + Ok(decoded) => { + let sample_buffer = match self.sample_buffer.as_mut() { + Some(buffer) => buffer, + None => { + let spec = *decoded.spec(); + let duration = decoded.capacity() as u64; + self.sample_buffer.insert(SampleBuffer::new(duration, spec)) + } + }; + + sample_buffer.copy_interleaved_ref(decoded); + let samples = AudioPacket::Samples(sample_buffer.samples().to_vec()); + + return Ok(Some((packet_position, samples))); + } + Err(Error::DecodeError(_)) => { + // The packet failed to decode due to corrupted or invalid data, get a new + // packet and try again. + warn!("Skipping malformed audio packet at {position_ms} ms"); + skipped = true; + continue; + } + Err(err) => return Err(err.into()), + } + } + } +} diff --git a/playback/src/dither.rs b/playback/src/dither.rs index 0f667917..d0825587 100644 --- a/playback/src/dither.rs +++ b/playback/src/dither.rs @@ -1,9 +1,9 @@ -use rand::rngs::SmallRng; use rand::SeedableRng; +use rand::rngs::SmallRng; use rand_distr::{Distribution, Normal, Triangular, Uniform}; use std::fmt; -const NUM_CHANNELS: usize = 2; +use crate::NUM_CHANNELS; // Dithering lowers digital-to-analog conversion ("requantization") error, // linearizing output, lowering distortion and replacing it with a constant, @@ -43,7 +43,7 @@ impl fmt::Display for dyn Ditherer { } fn create_rng() -> SmallRng { - SmallRng::from_entropy() + SmallRng::from_os_rng() } pub struct TriangularDitherer { @@ -64,6 +64,7 @@ impl Ditherer for TriangularDitherer { Self::NAME } + #[inline] fn noise(&mut self) -> f64 { self.distribution.sample(&mut self.cached_rng) } @@ -82,8 +83,15 @@ impl Ditherer for GaussianDitherer { fn new() -> Self { Self { cached_rng: create_rng(), - // 1/2 LSB RMS needed to linearize the response: - distribution: Normal::new(0.0, 0.5).unwrap(), + // For Gaussian to achieve equivalent decorrelation to triangular dithering, it needs + // 3-4 dB higher amplitude than TPDF's optimal 0.408 LSB. If optimizing: + // - minimum correlation: σ ≈ 0.58 + // - perceptual equivalence: σ ≈ 0.65 + // - worst-case performance: σ ≈ 0.70 + // + // σ = 0.6 LSB is a reasonable compromise that balances mathematical theory with + // empirical performance across various signal types. + distribution: Normal::new(0.0, 0.6).unwrap(), } } @@ -91,6 +99,7 @@ impl Ditherer for GaussianDitherer { Self::NAME } + #[inline] fn noise(&mut self) -> f64 { self.distribution.sample(&mut self.cached_rng) } @@ -102,7 +111,7 @@ impl GaussianDitherer { pub struct HighPassDitherer { active_channel: usize, - previous_noises: [f64; NUM_CHANNELS], + previous_noises: [f64; NUM_CHANNELS as usize], cached_rng: SmallRng, distribution: Uniform, } @@ -111,9 +120,11 @@ impl Ditherer for HighPassDitherer { fn new() -> Self { Self { active_channel: 0, - previous_noises: [0.0; NUM_CHANNELS], + previous_noises: [0.0; NUM_CHANNELS as usize], cached_rng: create_rng(), - distribution: Uniform::new_inclusive(-0.5, 0.5), // 1 LSB +/- 1 LSB (previous) = 2 LSB + // 1 LSB +/- 1 LSB (previous) = 2 LSB + distribution: Uniform::new_inclusive(-0.5, 0.5) + .expect("Failed to create uniform distribution"), } } @@ -121,6 +132,7 @@ impl Ditherer for HighPassDitherer { Self::NAME } + #[inline] fn noise(&mut self) -> f64 { let new_noise = self.distribution.sample(&mut self.cached_rng); let high_passed_noise = new_noise - self.previous_noises[self.active_channel]; diff --git a/playback/src/lib.rs b/playback/src/lib.rs index a52ca2fa..43a5b4f0 100644 --- a/playback/src/lib.rs +++ b/playback/src/lib.rs @@ -15,6 +15,6 @@ 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 SAMPLES_PER_SECOND: u32 = SAMPLE_RATE * 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 81d0436f..90daaf17 100644 --- a/playback/src/mixer/alsamixer.rs +++ b/playback/src/mixer/alsamixer.rs @@ -3,13 +3,17 @@ use crate::player::{db_to_ratio, ratio_to_db}; use super::mappings::{LogMapping, MappedCtrl, VolumeMapping}; use super::{Mixer, MixerConfig, VolumeCtrl}; +use alsa::Error as AlsaError; use alsa::ctl::{ElemId, ElemIface}; use alsa::mixer::{MilliBel, SelemChannelId, SelemId}; use alsa::{Ctl, Round}; -use std::ffi::CString; +use librespot_core::Error; +use std::ffi::{CString, NulError}; +use thiserror::Error; #[derive(Clone)] +#[allow(dead_code)] pub struct AlsaMixer { config: MixerConfig, min: i64, @@ -28,8 +32,30 @@ pub struct AlsaMixer { const SND_CTL_TLV_DB_GAIN_MUTE: MilliBel = MilliBel(-9999999); const ZERO_DB: MilliBel = MilliBel(0); +#[derive(Debug, Error)] +enum AlsaMixerError { + #[error("Could not open Alsa mixer. {0}")] + CouldNotOpen(AlsaError), + #[error("Could not find Alsa mixer control")] + CouldNotFindController, + #[error("Could not open Alsa softvol with that device. {0}")] + CouldNotOpenWithDevice(AlsaError), + #[error("Could not open Alsa softvol with that name. {0}")] + CouldNotOpenWithName(NulError), + #[error("Could not get Alsa softvol dB range. {0}")] + NoDbRange(AlsaError), + #[error("Could not convert Alsa raw volume to dB volume. {0}")] + CouldNotConvertRaw(AlsaError), +} + +impl From for Error { + fn from(value: AlsaMixerError) -> Self { + Error::failed_precondition(value) + } +} + impl Mixer for AlsaMixer { - fn open(config: MixerConfig) -> Self { + fn open(config: MixerConfig) -> Result { info!( "Mixing with Alsa and volume control: {:?} for device: {} with mixer control: {},{}", config.volume_ctrl, config.device, config.control, config.index, @@ -38,10 +64,10 @@ impl Mixer for AlsaMixer { let mut config = config; // clone let mixer = - alsa::mixer::Mixer::new(&config.device, false).expect("Could not open Alsa mixer"); + alsa::mixer::Mixer::new(&config.device, false).map_err(AlsaMixerError::CouldNotOpen)?; let simple_element = mixer .find_selem(&SelemId::new(&config.control, config.index)) - .expect("Could not find Alsa mixer control"); + .ok_or(AlsaMixerError::CouldNotFindController)?; // Query capabilities let has_switch = simple_element.has_playback_switch(); @@ -56,17 +82,17 @@ 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.device, false) - .expect("Could not open Alsa softvol with that device"); + let control = + Ctl::new(&config.device, false).map_err(AlsaMixerError::CouldNotOpenWithDevice)?; let mut element_id = ElemId::new(ElemIface::Mixer); element_id.set_name( &CString::new(config.control.as_str()) - .expect("Could not open Alsa softvol with that name"), + .map_err(AlsaMixerError::CouldNotOpenWithName)?, ); element_id.set_index(config.index); let (min_millibel, mut max_millibel) = control .get_db_range(&element_id) - .expect("Could not get Alsa softvol dB range"); + .map_err(AlsaMixerError::NoDbRange)?; // Alsa can report incorrect maximum volumes due to rounding // errors. e.g. Alsa rounds [-60.0..0.0] in range [0..255] to @@ -80,10 +106,14 @@ impl Mixer for AlsaMixer { let reported_step_size = (max_millibel - min_millibel).0 / range; let assumed_step_size = (ZERO_DB - min_millibel).0 / range; if reported_step_size == assumed_step_size { - warn!("Alsa rounding error detected, setting maximum dB to {:.2} instead of {:.2}", ZERO_DB.to_db(), max_millibel.to_db()); + warn!( + "Alsa rounding error detected, setting maximum dB to {:.2} instead of {:.2}", + ZERO_DB.to_db(), + max_millibel.to_db() + ); max_millibel = ZERO_DB; } else { - warn!("Please manually set with `--volume-ctrl` if this is incorrect"); + warn!("Please manually set `--volume-range` if this is incorrect"); } } (min_millibel, max_millibel) @@ -96,19 +126,40 @@ impl Mixer for AlsaMixer { debug!("Alsa mixer reported minimum dB as mute, trying workaround"); min_millibel = simple_element .ask_playback_vol_db(min + 1) - .expect("Could not convert Alsa raw volume to dB volume"); + .map_err(AlsaMixerError::CouldNotConvertRaw)?; } (min_millibel, max_millibel) }; let min_db = min_millibel.to_db() as f64; let max_db = max_millibel.to_db() as f64; - let db_range = f64::abs(max_db - min_db); + let reported_db_range = f64::abs(max_db - min_db); // Synchronize the volume control dB range with the mixer control, // unless it was already set with a command line option. - if !config.volume_ctrl.range_ok() { - config.volume_ctrl.set_db_range(db_range); + let db_range = if config.volume_ctrl.range_ok() { + let db_range_override = config.volume_ctrl.db_range(); + if db_range_override.is_normal() { + db_range_override + } else { + reported_db_range + } + } else { + config.volume_ctrl.set_db_range(reported_db_range); + reported_db_range + }; + + if reported_db_range == db_range { + debug!("Alsa dB volume range was reported as {}", reported_db_range); + if reported_db_range > 100.0 { + debug!("Alsa mixer reported dB range > 100, which is suspect"); + debug!("Please manually set `--volume-range` if this is incorrect"); + } + } else { + debug!( + "Alsa dB volume range was reported as {} but overridden to {}", + reported_db_range, db_range + ); } // For hardware controls with a small range (24 dB or less), @@ -128,7 +179,7 @@ impl Mixer for AlsaMixer { ); debug!("Alsa forcing linear dB mapping: {}", use_linear_in_db); - Self { + Ok(Self { config, min, max, @@ -139,7 +190,7 @@ impl Mixer for AlsaMixer { has_switch, is_softvol, use_linear_in_db, - } + }) } fn volume(&self) -> u16 { @@ -179,7 +230,7 @@ impl Mixer for AlsaMixer { mapped_volume = LogMapping::linear_to_mapped(mapped_volume, self.db_range); } - self.config.volume_ctrl.from_mapped(mapped_volume) + self.config.volume_ctrl.as_unmapped(mapped_volume) } fn set_volume(&self, volume: u16) { diff --git a/playback/src/mixer/mappings.rs b/playback/src/mixer/mappings.rs index 04cef439..cb238a21 100644 --- a/playback/src/mixer/mappings.rs +++ b/playback/src/mixer/mappings.rs @@ -3,7 +3,7 @@ use crate::player::db_to_ratio; pub trait MappedCtrl { fn to_mapped(&self, volume: u16) -> f64; - fn from_mapped(&self, mapped_volume: f64) -> u16; + fn as_unmapped(&self, mapped_volume: f64) -> u16; fn db_range(&self) -> f64; fn set_db_range(&mut self, new_db_range: f64); @@ -33,10 +33,7 @@ impl MappedCtrl for VolumeCtrl { } } else { // Ensure not to return -inf or NaN due to division by zero. - error!( - "{:?} does not work with 0 dB range, using linear mapping instead", - self - ); + error!("{self:?} does not work with 0 dB range, using linear mapping instead"); normalized_volume }; @@ -49,7 +46,7 @@ impl MappedCtrl for VolumeCtrl { mapped_volume } - fn from_mapped(&self, mapped_volume: f64) -> u16 { + fn as_unmapped(&self, mapped_volume: f64) -> u16 { // More than just an optimization, this ensures that zero mapped volume // is unmapped to non-negative real numbers (otherwise the log and cubic // equations would respectively return -inf and -1/9.) @@ -67,10 +64,7 @@ impl MappedCtrl for VolumeCtrl { } } else { // Ensure not to return -inf or NaN due to division by zero. - error!( - "{:?} does not work with 0 dB range, using linear mapping instead", - self - ); + error!("{self:?} does not work with 0 dB range, using linear mapping instead"); mapped_volume }; @@ -87,11 +81,11 @@ impl MappedCtrl for VolumeCtrl { fn set_db_range(&mut self, new_db_range: f64) { match self { - Self::Cubic(ref mut db_range) | Self::Log(ref mut db_range) => *db_range = new_db_range, - _ => error!("Invalid to set dB range for volume control type {:?}", self), + Self::Cubic(db_range) | Self::Log(db_range) => *db_range = new_db_range, + _ => error!("Invalid to set dB range for volume control type {self:?}"), } - debug!("Volume control is now {:?}", self) + debug!("Volume control is now {self:?}") } fn range_ok(&self) -> bool { @@ -158,6 +152,6 @@ impl CubicMapping { fn min_norm(db_range: f64) -> f64 { // Note that this 60.0 is unrelated to DEFAULT_DB_RANGE. // Instead, it's the cubic voltage to dB ratio. - f64::powf(10.0, -1.0 * db_range / 60.0) + f64::powf(10.0, -db_range / 60.0) } } diff --git a/playback/src/mixer/mod.rs b/playback/src/mixer/mod.rs index 5397598f..89d03235 100644 --- a/playback/src/mixer/mod.rs +++ b/playback/src/mixer/mod.rs @@ -1,23 +1,34 @@ use crate::config::VolumeCtrl; +use librespot_core::Error; +use std::sync::Arc; pub mod mappings; use self::mappings::MappedCtrl; -pub trait Mixer: Send { - fn open(config: MixerConfig) -> Self +pub struct NoOpVolume; + +pub trait Mixer: Send + Sync { + fn open(config: MixerConfig) -> Result where Self: Sized; - fn set_volume(&self, volume: u16); fn volume(&self) -> u16; + fn set_volume(&self, volume: u16); - fn get_audio_filter(&self) -> Option> { - None + fn get_soft_volume(&self) -> Box { + Box::new(NoOpVolume) } } -pub trait AudioFilter { - fn modify_stream(&self, data: &mut [f64]); +pub trait VolumeGetter { + fn attenuation_factor(&self) -> f64; +} + +impl VolumeGetter for NoOpVolume { + #[inline] + fn attenuation_factor(&self) -> f64 { + 1.0 + } } pub mod softmixer; @@ -47,17 +58,25 @@ impl Default for MixerConfig { } } -pub type MixerFn = fn(MixerConfig) -> Box; +pub type MixerFn = fn(MixerConfig) -> Result, Error>; -fn mk_sink(config: MixerConfig) -> Box { - Box::new(M::open(config)) +fn mk_sink(config: MixerConfig) -> Result, Error> { + Ok(Arc::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 cefc2de5..189fc151 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -1,9 +1,10 @@ -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; - -use super::AudioFilter; +use super::VolumeGetter; use super::{MappedCtrl, VolumeCtrl}; use super::{Mixer, MixerConfig}; +use librespot_core::Error; +use portable_atomic::AtomicU64; +use std::sync::Arc; +use std::sync::atomic::Ordering; #[derive(Clone)] pub struct SoftMixer { @@ -14,19 +15,19 @@ pub struct SoftMixer { } impl Mixer for SoftMixer { - fn open(config: MixerConfig) -> Self { + fn open(config: MixerConfig) -> Result { let volume_ctrl = config.volume_ctrl; - info!("Mixing with softvol and volume control: {:?}", volume_ctrl); + info!("Mixing with softvol and volume control: {volume_ctrl:?}"); - Self { + Ok(Self { volume: Arc::new(AtomicU64::new(f64::to_bits(0.5))), volume_ctrl, - } + }) } fn volume(&self) -> u16 { let mapped_volume = f64::from_bits(self.volume.load(Ordering::Relaxed)); - self.volume_ctrl.from_mapped(mapped_volume) + self.volume_ctrl.as_unmapped(mapped_volume) } fn set_volume(&self, volume: u16) { @@ -35,10 +36,8 @@ impl Mixer for SoftMixer { .store(mapped_volume.to_bits(), Ordering::Relaxed) } - fn get_audio_filter(&self) -> Option> { - Some(Box::new(SoftVolumeApplier { - volume: self.volume.clone(), - })) + fn get_soft_volume(&self) -> Box { + Box::new(SoftVolume(self.volume.clone())) } } @@ -46,17 +45,11 @@ impl SoftMixer { pub const NAME: &'static str = "softvol"; } -struct SoftVolumeApplier { - volume: Arc, -} +struct SoftVolume(Arc); -impl AudioFilter for SoftVolumeApplier { - fn modify_stream(&self, data: &mut [f64]) { - let volume = f64::from_bits(self.volume.load(Ordering::Relaxed)); - if volume < 1.0 { - for x in data.iter_mut() { - *x *= volume; - } - } +impl VolumeGetter for SoftVolume { + #[inline] + fn attenuation_factor(&self) -> f64 { + f64::from_bits(self.0.load(Ordering::Relaxed)) } } diff --git a/playback/src/player.rs b/playback/src/player.rs index a7ff916d..a4a03ca3 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -1,44 +1,61 @@ -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::{ + collections::HashMap, + fmt, + future::Future, + io::{self, Read, Seek, SeekFrom}, + mem, + pin::Pin, + process::exit, + sync::Mutex, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll}, + thread, + time::{Duration, Instant}, +}; -use byteorder::{LittleEndian, ReadBytesExt}; -use futures_util::stream::futures_unordered::FuturesUnordered; -use futures_util::{future, StreamExt, TryFutureExt}; +#[cfg(feature = "passthrough-decoder")] +use crate::decoder::PassthroughDecoder; +use crate::{ + audio::{AudioDecrypt, AudioFetchParams, AudioFile, StreamLoaderController}, + audio_backend::Sink, + config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}, + convert::Converter, + core::{Error, Session, SpotifyId, SpotifyUri, util::SeqGenerator}, + decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder}, + metadata::audio::{AudioFileFormat, AudioFiles, AudioItem}, + mixer::VolumeGetter, +}; +use futures_util::{ + StreamExt, TryFutureExt, future, future::FusedFuture, + stream::futures_unordered::FuturesUnordered, +}; +use librespot_metadata::track::Tracks; +use symphonia::core::io::MediaSource; use tokio::sync::{mpsc, oneshot}; -use crate::audio::{AudioDecrypt, AudioFile, StreamLoaderController}; -use crate::audio::{ - READ_AHEAD_BEFORE_PLAYBACK, READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK, - READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, -}; -use crate::audio_backend::Sink; -use crate::config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}; -use crate::convert::Converter; -use crate::core::session::Session; -use crate::core::spotify_id::SpotifyId; -use crate::core::util::SeqGenerator; -use crate::decoder::{AudioDecoder, AudioPacket, DecoderError, PassthroughDecoder, VorbisDecoder}; -use crate::metadata::{AudioItem, FileFormat}; -use crate::mixer::AudioFilter; - -use crate::{MS_PER_PAGE, NUM_CHANNELS, PAGES_PER_MS, SAMPLES_PER_SECOND}; +use crate::SAMPLES_PER_SECOND; const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000; pub const DB_VOLTAGE_RATIO: f64 = 20.0; +pub const PCM_AT_0DBFS: f64 = 1.0; + +// Spotify inserts a custom Ogg packet at the start with custom metadata values, that you would +// otherwise expect in Vorbis comments. This packet isn't well-formed and players may balk at it. +const SPOTIFY_OGG_HEADER_END: u64 = 0xa7; + +const LOAD_HANDLES_POISON_MSG: &str = "load handles mutex should not be poisoned"; + +pub type PlayerResult = Result<(), Error>; pub struct Player { commands: Option>, thread_handle: Option>, - play_request_id_generator: SeqGenerator, } -#[derive(PartialEq, Debug, Clone, Copy)] +#[derive(PartialEq, Eq, Debug, Clone, Copy)] pub enum SinkStatus { Running, Closed, @@ -51,75 +68,92 @@ struct PlayerInternal { session: Session, config: PlayerConfig, commands: mpsc::UnboundedReceiver, + load_handles: Arc>>>, state: PlayerState, preload: PlayerPreload, sink: Box, sink_status: SinkStatus, sink_event_callback: Option, - audio_filter: Option>, + volume_getter: Box, event_senders: Vec>, converter: Converter, - limiter_active: bool, - limiter_attack_counter: u32, - limiter_release_counter: u32, - limiter_peak_sample: f64, - limiter_factor: f64, - limiter_strength: f64, + normalisation_integrators: [f64; 2], + normalisation_peaks: [f64; 2], + normalisation_channel: usize, + normalisation_knee_factor: f64, auto_normalise_as_album: bool, + + player_id: usize, + play_request_id_generator: SeqGenerator, + last_progress_update: Instant, } +static PLAYER_COUNTER: AtomicUsize = AtomicUsize::new(0); + enum PlayerCommand { Load { - track_id: SpotifyId, - play_request_id: u64, + track_id: SpotifyUri, play: bool, position_ms: u32, }, Preload { - track_id: SpotifyId, + track_id: SpotifyUri, }, Play, Pause, Stop, Seek(u32), + SetSession(Session), AddEventSender(mpsc::UnboundedSender), SetSinkEventCallback(Option), - EmitVolumeSetEvent(u16), + EmitVolumeChangedEvent(u16), SetAutoNormaliseAsAlbum(bool), + EmitSessionDisconnectedEvent { + connection_id: String, + user_name: String, + }, + EmitSessionConnectedEvent { + connection_id: String, + user_name: String, + }, + EmitSessionClientChangedEvent { + client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, + }, + EmitFilterExplicitContentChangedEvent(bool), + EmitShuffleChangedEvent(bool), + EmitRepeatChangedEvent { + context: bool, + track: bool, + }, + EmitAutoPlayChangedEvent(bool), } #[derive(Debug, Clone)] pub enum PlayerEvent { + // Play request id changed + PlayRequestIdChanged { + play_request_id: u64, + }, // Fired when the player is stopped (e.g. by issuing a "stop" command to the player). Stopped { play_request_id: u64, - track_id: SpotifyId, - }, - // The player started working on playback of a track while it was in a stopped state. - // This is always immediately followed up by a "Loading" or "Playing" event. - Started { - play_request_id: u64, - track_id: SpotifyId, - position_ms: u32, - }, - // Same as started but in the case that the player already had a track loaded. - // The player was either playing the loaded track or it was paused. - Changed { - old_track_id: SpotifyId, - new_track_id: SpotifyId, + track_id: SpotifyUri, }, // The player is delayed by loading a track. Loading { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, }, // The player is preloading a track. Preloading { - track_id: SpotifyId, + track_id: SpotifyUri, }, // The player is playing a track. // This event is issued at the start of playback of whenever the position must be communicated @@ -130,39 +164,84 @@ pub enum PlayerEvent { // after a buffer-underrun Playing { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, - duration_ms: u32, }, // The player entered a paused state. Paused { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, - duration_ms: u32, }, // The player thinks it's a good idea to issue a preload command for the next track now. // This event is intended for use within spirc. TimeToPreloadNextTrack { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, }, // The player reached the end of a track. - // This event is intended for use within spirc. Spirc will respond by issuing another command - // which will trigger another event (e.g. Changed or Stopped) + // This event is intended for use within spirc. Spirc will respond by issuing another command. EndOfTrack { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, }, // The player was unable to load the requested track. Unavailable { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, }, // The mixer volume was set to a new level. - VolumeSet { + VolumeChanged { volume: u16, }, + PositionCorrection { + play_request_id: u64, + track_id: SpotifyUri, + position_ms: u32, + }, + /// Requires `PlayerConfig::position_update_interval` to be set to Some. + /// Once set this event will be sent periodically while playing the track to inform about the + /// current playback position + PositionChanged { + play_request_id: u64, + track_id: SpotifyUri, + position_ms: u32, + }, + Seeked { + play_request_id: u64, + track_id: SpotifyUri, + position_ms: u32, + }, + TrackChanged { + audio_item: Box, + }, + SessionConnected { + connection_id: String, + user_name: String, + }, + SessionDisconnected { + connection_id: String, + user_name: String, + }, + SessionClientChanged { + client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, + }, + ShuffleChanged { + shuffle: bool, + }, + RepeatChanged { + context: bool, + track: bool, + }, + AutoPlayChanged { + auto_play: bool, + }, + FilterExplicitContentChanged { + filter: bool, + }, } impl PlayerEvent { @@ -175,9 +254,6 @@ impl PlayerEvent { | Unavailable { play_request_id, .. } - | Started { - play_request_id, .. - } | Playing { play_request_id, .. } @@ -192,48 +268,90 @@ impl PlayerEvent { } | Stopped { play_request_id, .. + } + | PositionCorrection { + play_request_id, .. + } + | Seeked { + play_request_id, .. } => Some(*play_request_id), - Changed { .. } | Preloading { .. } | VolumeSet { .. } => None, + _ => None, } } } pub type PlayerEventChannel = mpsc::UnboundedReceiver; +#[inline] pub fn db_to_ratio(db: f64) -> f64 { f64::powf(10.0, db / DB_VOLTAGE_RATIO) } +#[inline] pub fn ratio_to_db(ratio: f64) -> f64 { ratio.log10() * DB_VOLTAGE_RATIO } +pub fn duration_to_coefficient(duration: Duration) -> f64 { + f64::exp(-1.0 / (duration.as_secs_f64() * SAMPLES_PER_SECOND as f64)) +} + +pub fn coefficient_to_duration(coefficient: f64) -> Duration { + Duration::from_secs_f64(-1.0 / f64::ln(coefficient) / SAMPLES_PER_SECOND as f64) +} + #[derive(Clone, Copy, Debug)] pub struct NormalisationData { - track_gain_db: f32, - track_peak: f32, - album_gain_db: f32, - album_peak: f32, + // Spotify provides these as `f32`, but audio metadata can contain up to `f64`. + // Also, this negates the need for casting during sample processing. + pub track_gain_db: f64, + pub track_peak: f64, + pub album_gain_db: f64, + pub album_peak: f64, +} + +impl Default for NormalisationData { + fn default() -> Self { + Self { + track_gain_db: 0.0, + track_peak: 1.0, + album_gain_db: 0.0, + album_peak: 1.0, + } + } } impl NormalisationData { - fn parse_from_file(mut file: T) -> io::Result { + fn parse_from_ogg(mut file: T) -> io::Result { const SPOTIFY_NORMALIZATION_HEADER_START_OFFSET: u64 = 144; - file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?; + const NORMALISATION_DATA_SIZE: usize = 16; - let track_gain_db = file.read_f32::()?; - let track_peak = file.read_f32::()?; - let album_gain_db = file.read_f32::()?; - let album_peak = file.read_f32::()?; + 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 {SPOTIFY_NORMALIZATION_HEADER_START_OFFSET} but position is now {newpos}" + ); - let r = NormalisationData { + error!("Falling back to default (non-track and non-album) normalisation data."); + + return Ok(NormalisationData::default()); + } + + let mut buf = [0u8; NORMALISATION_DATA_SIZE]; + + file.read_exact(&mut buf)?; + + let track_gain_db = f32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as f64; + let track_peak = f32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as f64; + let album_gain_db = f32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]) as f64; + let album_peak = f32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]) as f64; + + Ok(Self { track_gain_db, track_peak, album_gain_db, album_peak, - }; - - Ok(r) + }) } fn get_factor(config: &PlayerConfig, data: NormalisationData) -> f64 { @@ -241,40 +359,70 @@ impl NormalisationData { return 1.0; } - let [gain_db, gain_peak] = if config.normalisation_type == NormalisationType::Album { - [data.album_gain_db, data.album_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] + (data.track_gain_db, data.track_peak) }; - let normalisation_power = gain_db as f64 + config.normalisation_pregain; - let mut normalisation_factor = db_to_ratio(normalisation_power); + // As per the ReplayGain 1.0 & 2.0 (proposed) spec: + // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification#Clipping_prevention + // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Clipping_prevention + let normalisation_factor = if config.normalisation_method == NormalisationMethod::Basic { + // For Basic Normalisation, factor = min(ratio of (ReplayGain + PreGain), 1.0 / peak level). + // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification#Peak_amplitude + // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Peak_amplitude + // We then limit that to 1.0 as not to exceed dBFS (0.0 dB). + let factor = f64::min( + db_to_ratio(gain_db + config.normalisation_pregain_db), + PCM_AT_0DBFS / gain_peak, + ); - if normalisation_factor * gain_peak as f64 > config.normalisation_threshold { - let limited_normalisation_factor = config.normalisation_threshold / gain_peak as f64; - let limited_normalisation_power = ratio_to_db(limited_normalisation_factor); + if factor > PCM_AT_0DBFS { + info!( + "Lowering gain by {:.2} dB for the duration of this track to avoid potentially exceeding dBFS.", + ratio_to_db(factor) + ); - if config.normalisation_method == NormalisationMethod::Basic { - warn!("Limiting gain to {:.2} dB for the duration of this track to stay under normalisation threshold.", limited_normalisation_power); - normalisation_factor = limited_normalisation_factor; + PCM_AT_0DBFS } else { + factor + } + } else { + // For Dynamic Normalisation it's up to the player to decide, + // factor = ratio of (ReplayGain + PreGain). + // We then let the dynamic limiter handle gain reduction. + let factor = db_to_ratio(gain_db + config.normalisation_pregain_db); + let threshold_ratio = db_to_ratio(config.normalisation_threshold_dbfs); + + if factor > PCM_AT_0DBFS { + let factor_db = gain_db + config.normalisation_pregain_db; + let limiting_db = factor_db + config.normalisation_threshold_dbfs.abs(); + warn!( - "This track will at its peak be subject to {:.2} dB of dynamic limiting.", - normalisation_power - limited_normalisation_power + "This track may exceed dBFS by {factor_db:.2} dB and be subject to {limiting_db:.2} dB of dynamic limiting at its peak." + ); + } else if factor > threshold_ratio { + let limiting_db = gain_db + + config.normalisation_pregain_db + + config.normalisation_threshold_dbfs.abs(); + + info!( + "This track may be subject to {limiting_db:.2} dB of dynamic limiting at its peak." ); } - warn!("Please lower pregain to avoid."); - } + factor + }; - debug!("Normalisation Data: {:?}", data); + debug!("Normalisation Data: {data:?}"); debug!( "Calculated Normalisation Factor for {:?}: {:.2}%", config.normalisation_type, normalisation_factor * 100.0 ); - normalisation_factor as f64 + normalisation_factor } } @@ -282,100 +430,112 @@ impl Player { pub fn new( config: PlayerConfig, session: Session, - audio_filter: Option>, + volume_getter: Box, sink_builder: F, - ) -> (Player, PlayerEventChannel) + ) -> Arc where F: FnOnce() -> Box + Send + 'static, { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let (event_sender, event_receiver) = mpsc::unbounded_channel(); if config.normalisation { debug!("Normalisation Type: {:?}", config.normalisation_type); debug!( "Normalisation Pregain: {:.1} dB", - config.normalisation_pregain + config.normalisation_pregain_db ); debug!( "Normalisation Threshold: {:.1} dBFS", - ratio_to_db(config.normalisation_threshold) + config.normalisation_threshold_dbfs ); debug!("Normalisation Method: {:?}", config.normalisation_method); if config.normalisation_method == NormalisationMethod::Dynamic { - debug!("Normalisation Attack: {:?}", config.normalisation_attack); - debug!("Normalisation Release: {:?}", config.normalisation_release); - debug!("Normalisation Knee: {:?}", config.normalisation_knee); + // as_millis() has rounding errors (truncates) + debug!( + "Normalisation Attack: {:.0} ms", + coefficient_to_duration(config.normalisation_attack_cf).as_secs_f64() * 1000. + ); + debug!( + "Normalisation Release: {:.0} ms", + coefficient_to_duration(config.normalisation_release_cf).as_secs_f64() * 1000. + ); + debug!("Normalisation Knee: {} dB", config.normalisation_knee_db); } } let handle = thread::spawn(move || { - debug!("new Player[{}]", session.session_id()); + let player_id = PLAYER_COUNTER.fetch_add(1, Ordering::AcqRel); + debug!("new Player [{player_id}]"); let converter = Converter::new(config.ditherer); + let normalisation_knee_factor = 1.0 / (8.0 * config.normalisation_knee_db); let internal = PlayerInternal { session, config, commands: cmd_rx, + load_handles: Arc::new(Mutex::new(HashMap::new())), state: PlayerState::Stopped, preload: PlayerPreload::None, sink: sink_builder(), sink_status: SinkStatus::Closed, sink_event_callback: None, - audio_filter, - event_senders: [event_sender].to_vec(), + volume_getter, + event_senders: vec![], converter, - limiter_active: false, - limiter_attack_counter: 0, - limiter_release_counter: 0, - limiter_peak_sample: 0.0, - limiter_factor: 1.0, - limiter_strength: 0.0, + normalisation_peaks: [0.0; 2], + normalisation_integrators: [0.0; 2], + normalisation_channel: 0, + normalisation_knee_factor, auto_normalise_as_album: false, + + player_id, + play_request_id_generator: SeqGenerator::new(0), + last_progress_update: Instant::now(), }; // 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."); }); - ( - Player { - commands: Some(cmd_tx), - thread_handle: Some(handle), - play_request_id_generator: SeqGenerator::new(0), - }, - event_receiver, - ) + Arc::new(Self { + commands: Some(cmd_tx), + thread_handle: Some(handle), + }) + } + + pub fn is_invalid(&self) -> bool { + if let Some(handle) = self.thread_handle.as_ref() { + return handle.is_finished(); + } + true } fn command(&self, cmd: PlayerCommand) { if let Some(commands) = self.commands.as_ref() { if let Err(e) = commands.send(cmd) { - error!("Player Commands Error: {}", e); + error!("Player Commands Error: {e}"); } } } - pub fn load(&mut self, track_id: SpotifyId, start_playing: bool, position_ms: u32) -> u64 { - let play_request_id = self.play_request_id_generator.get(); + pub fn load(&self, track_id: SpotifyUri, start_playing: bool, position_ms: u32) { self.command(PlayerCommand::Load { track_id, - play_request_id, play: start_playing, position_ms, }); - - play_request_id } - pub fn preload(&self, track_id: SpotifyId) { + pub fn preload(&self, track_id: SpotifyUri) { self.command(PlayerCommand::Preload { track_id }); } @@ -395,6 +555,10 @@ impl Player { self.command(PlayerCommand::Seek(position_ms)); } + pub fn set_session(&self, session: Session) { + self.command(PlayerCommand::SetSession(session)); + } + pub fn get_player_event_channel(&self) -> PlayerEventChannel { let (event_sender, event_receiver) = mpsc::unbounded_channel(); self.command(PlayerCommand::AddEventSender(event_sender)); @@ -417,13 +581,58 @@ impl Player { self.command(PlayerCommand::SetSinkEventCallback(callback)); } - pub fn emit_volume_set_event(&self, volume: u16) { - self.command(PlayerCommand::EmitVolumeSetEvent(volume)); + pub fn emit_volume_changed_event(&self, volume: u16) { + self.command(PlayerCommand::EmitVolumeChangedEvent(volume)); } pub fn set_auto_normalise_as_album(&self, setting: bool) { self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting)); } + + pub fn emit_filter_explicit_content_changed_event(&self, filter: bool) { + self.command(PlayerCommand::EmitFilterExplicitContentChangedEvent(filter)); + } + + pub fn emit_session_connected_event(&self, connection_id: String, user_name: String) { + self.command(PlayerCommand::EmitSessionConnectedEvent { + connection_id, + user_name, + }); + } + + pub fn emit_session_disconnected_event(&self, connection_id: String, user_name: String) { + self.command(PlayerCommand::EmitSessionDisconnectedEvent { + connection_id, + user_name, + }); + } + + pub fn emit_session_client_changed_event( + &self, + client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, + ) { + self.command(PlayerCommand::EmitSessionClientChangedEvent { + client_id, + client_name, + client_brand_name, + client_model_name, + }); + } + + pub fn emit_shuffle_changed_event(&self, shuffle: bool) { + self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle)); + } + + pub fn emit_repeat_changed_event(&self, context: bool, track: bool) { + self.command(PlayerCommand::EmitRepeatChangedEvent { context, track }); + } + + pub fn emit_auto_play_changed_event(&self, auto_play: bool) { + self.command(PlayerCommand::EmitAutoPlayChangedEvent(auto_play)); + } } impl Drop for Player { @@ -431,9 +640,8 @@ impl Drop for Player { debug!("Shutting down player thread ..."); self.commands = None; if let Some(handle) = self.thread_handle.take() { - match handle.join() { - Ok(_) => (), - Err(e) => error!("Player thread Error: {:?}", e), + if let Err(e) = handle.join() { + error!("Player thread Error: {e:?}"); } } } @@ -443,19 +651,21 @@ struct PlayerLoadedTrackData { decoder: Decoder, normalisation_data: NormalisationData, stream_loader_controller: StreamLoaderController, + audio_item: AudioItem, bytes_per_second: usize, duration_ms: u32, - stream_position_pcm: u64, + stream_position_ms: u32, + is_explicit: bool, } enum PlayerPreload { None, Loading { - track_id: SpotifyId, - loader: Pin> + Send>>, + track_id: SpotifyUri, + loader: Pin> + Send>>, }, Ready { - track_id: SpotifyId, + track_id: SpotifyUri, loaded_track: Box, }, } @@ -465,38 +675,42 @@ type Decoder = Box; enum PlayerState { Stopped, Loading { - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, start_playback: bool, - loader: Pin> + Send>>, + loader: Pin> + Send>>, }, Paused { - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, decoder: Decoder, + audio_item: AudioItem, normalisation_data: NormalisationData, normalisation_factor: f64, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, - stream_position_pcm: u64, + stream_position_ms: u32, suggested_to_preload_next_track: bool, + is_explicit: bool, }, Playing { - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, decoder: Decoder, normalisation_data: NormalisationData, + audio_item: AudioItem, normalisation_factor: f64, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, - stream_position_pcm: u64, + stream_position_ms: u32, reported_nominal_start_time: Option, suggested_to_preload_next_track: bool, + is_explicit: bool, }, EndOfTrack { - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, loaded_track: PlayerLoadedTrackData, }, @@ -510,7 +724,7 @@ impl PlayerState { Stopped | EndOfTrack { .. } | Paused { .. } | Loading { .. } => false, Playing { .. } => true, Invalid => { - error!("PlayerState is_playing: invalid state"); + error!("PlayerState::is_playing in invalid state"); exit(1); } } @@ -522,6 +736,7 @@ impl PlayerState { matches!(self, Stopped) } + #[allow(dead_code)] fn is_loading(&self) -> bool { use self::PlayerState::*; matches!(self, Loading { .. }) @@ -538,26 +753,7 @@ impl PlayerState { ref mut decoder, .. } => Some(decoder), Invalid => { - error!("PlayerState decoder: invalid state"); - exit(1); - } - } - } - - fn stream_loader_controller(&mut self) -> Option<&mut StreamLoaderController> { - use self::PlayerState::*; - match *self { - Stopped | EndOfTrack { .. } | Loading { .. } => None, - Paused { - ref mut stream_loader_controller, - .. - } - | Playing { - ref mut stream_loader_controller, - .. - } => Some(stream_loader_controller), - Invalid => { - error!("PlayerState stream_loader_controller: invalid state"); + error!("PlayerState::decoder in invalid state"); exit(1); } } @@ -565,7 +761,8 @@ impl PlayerState { fn playing_to_end_of_track(&mut self) { use self::PlayerState::*; - match mem::replace(self, Invalid) { + let new_state = mem::replace(self, Invalid); + match new_state { Playing { track_id, play_request_id, @@ -574,7 +771,9 @@ impl PlayerState { bytes_per_second, normalisation_data, stream_loader_controller, - stream_position_pcm, + stream_position_ms, + is_explicit, + audio_item, .. } => { *self = EndOfTrack { @@ -584,14 +783,16 @@ impl PlayerState { decoder, normalisation_data, stream_loader_controller, + audio_item, bytes_per_second, duration_ms, - stream_position_pcm, + stream_position_ms, + is_explicit, }, }; } _ => { - error!("Called playing_to_end_of_track in non-playing state."); + error!("Called playing_to_end_of_track in non-playing state: {new_state:?}"); exit(1); } } @@ -599,35 +800,41 @@ impl PlayerState { fn paused_to_playing(&mut self) { use self::PlayerState::*; - match ::std::mem::replace(self, Invalid) { + let new_state = mem::replace(self, Invalid); + match new_state { Paused { track_id, play_request_id, decoder, + audio_item, normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, bytes_per_second, - stream_position_pcm, + stream_position_ms, suggested_to_preload_next_track, + is_explicit, } => { *self = Playing { track_id, play_request_id, decoder, + audio_item, normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, bytes_per_second, - stream_position_pcm, - reported_nominal_start_time: None, + stream_position_ms, + reported_nominal_start_time: Instant::now() + .checked_sub(Duration::from_millis(stream_position_ms as u64)), suggested_to_preload_next_track, + is_explicit, }; } _ => { - error!("PlayerState paused_to_playing: invalid state"); + error!("PlayerState::paused_to_playing in invalid state: {new_state:?}"); exit(1); } } @@ -635,35 +842,40 @@ impl PlayerState { fn playing_to_paused(&mut self) { use self::PlayerState::*; - match ::std::mem::replace(self, Invalid) { + let new_state = mem::replace(self, Invalid); + match new_state { Playing { track_id, play_request_id, decoder, + audio_item, normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, bytes_per_second, - stream_position_pcm, - reported_nominal_start_time: _, + stream_position_ms, suggested_to_preload_next_track, + is_explicit, + .. } => { *self = Paused { track_id, play_request_id, decoder, + audio_item, normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, bytes_per_second, - stream_position_pcm, + stream_position_ms, suggested_to_preload_next_track, + is_explicit, }; } _ => { - error!("PlayerState playing_to_paused: invalid state"); + error!("PlayerState::playing_to_paused in invalid state: {new_state:?}"); exit(1); } } @@ -676,181 +888,240 @@ struct PlayerTrackLoader { } impl PlayerTrackLoader { - async fn find_available_alternative(&self, audio: AudioItem) -> Option { - if audio.available { - 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)) + async fn find_available_alternative(&self, audio_item: AudioItem) -> Option { + if let Err(e) = audio_item.availability { + error!("Track is unavailable: {e}"); + None + } else if !audio_item.files.is_empty() { + Some(audio_item) + } else if let Some(alternatives) = audio_item.alternatives { + let Tracks(alternatives_vec) = alternatives; // required to make `into_iter` able to move + + let alternatives: FuturesUnordered<_> = alternatives_vec + .into_iter() + .map(|alt_id| AudioItem::get_file(&self.session, alt_id)) .collect(); alternatives .filter_map(|x| future::ready(x.ok())) - .filter(|x| future::ready(x.available)) + .filter(|x| future::ready(x.availability.is_ok())) .next() .await } else { + error!("Track should be available, but no alternatives found."); None } } - fn stream_data_rate(&self, format: FileFormat) -> usize { - match format { - FileFormat::OGG_VORBIS_96 => 12 * 1024, - FileFormat::OGG_VORBIS_160 => 20 * 1024, - FileFormat::OGG_VORBIS_320 => 40 * 1024, - FileFormat::MP3_256 => 32 * 1024, - FileFormat::MP3_320 => 40 * 1024, - FileFormat::MP3_160 => 20 * 1024, - FileFormat::MP3_96 => 12 * 1024, - FileFormat::MP3_160_ENC => 20 * 1024, - FileFormat::MP4_128_DUAL => 16 * 1024, - FileFormat::OTHER3 => 40 * 1024, // better some high guess than nothing - FileFormat::AAC_160 => 20 * 1024, - FileFormat::AAC_320 => 40 * 1024, - FileFormat::MP4_128 => 16 * 1024, - FileFormat::OTHER5 => 40 * 1024, // better some high guess than nothing - } + fn stream_data_rate(&self, format: AudioFileFormat) -> Option { + 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::AAC_160 => 20., + AudioFileFormat::AAC_320 => 40., + AudioFileFormat::MP4_128 => 16., + AudioFileFormat::OTHER5 => 40., + AudioFileFormat::FLAC_FLAC => 112., // assume 900 kbit/s on average + AudioFileFormat::XHE_AAC_12 => 1.5, + AudioFileFormat::XHE_AAC_16 => 2., + AudioFileFormat::XHE_AAC_24 => 3., + AudioFileFormat::FLAC_FLAC_24BIT => 3., + }; + let data_rate: f32 = kbps * 1024.; + Some(data_rate.ceil() as usize) } async fn load_track( &self, - spotify_id: SpotifyId, + track_uri: SpotifyUri, position_ms: u32, ) -> Option { - let audio = match AudioItem::get_audio_item(&self.session, spotify_id).await { - Ok(audio) => audio, - Err(e) => { - error!("Unable to load audio item: {:?}", e); - return None; + match track_uri { + SpotifyUri::Track { .. } | SpotifyUri::Episode { .. } => { + self.load_remote_track(track_uri, position_ms).await } - }; - - info!("Loading <{}> with Spotify URI <{}>", audio.name, audio.uri); - - let audio = match self.find_available_alternative(audio).await { - Some(audio) => audio, - None => { - warn!("<{}> is not available", spotify_id.to_uri()); - return None; - } - }; - - assert!(audio.duration >= 0); - let duration_ms = audio.duration as u32; - - // (Most) podcasts seem to support only 96 bit Vorbis, so fall back to it - let formats = match self.config.bitrate { - Bitrate::Bitrate96 => [ - FileFormat::OGG_VORBIS_96, - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_320, - ], - Bitrate::Bitrate160 => [ - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_96, - FileFormat::OGG_VORBIS_320, - ], - Bitrate::Bitrate320 => [ - FileFormat::OGG_VORBIS_320, - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_96, - ], - }; - - let entry = formats.iter().find_map(|format| { - if let Some(&file_id) = audio.files.get(format) { - Some((*format, file_id)) - } else { + _ => { + error!("Cannot handle load of track with URI: <{track_uri}>",); None } - }); + } + } - let (format, file_id) = match entry { - Some(t) => t, - None => { - warn!("<{}> is not available in any supported format", audio.name); + async fn load_remote_track( + &self, + track_uri: SpotifyUri, + position_ms: u32, + ) -> Option { + let track_id: SpotifyId = match (&track_uri).try_into() { + Ok(id) => id, + Err(_) => { + warn!("<{track_uri}> could not be converted to a base62 ID"); return None; } }; - let bytes_per_second = self.stream_data_rate(format); - let play_from_beginning = position_ms == 0; + let audio_item = match AudioItem::get_file(&self.session, track_uri).await { + Ok(audio) => match self.find_available_alternative(audio).await { + Some(audio) => audio, + None => { + warn!( + "spotify:track:<{}> is not available", + track_id.to_base62().unwrap_or_default() + ); + return None; + } + }, + Err(e) => { + error!("Unable to load audio item: {e:?}"); + return None; + } + }; - // This is only a loop to be able to reload the file if an error occured + info!( + "Loading <{}> with Spotify URI <{}>", + audio_item.name, audio_item.uri + ); + + // (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it + let formats = match self.config.bitrate { + Bitrate::Bitrate96 => [ + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::MP3_96, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::MP3_160, + AudioFileFormat::MP3_256, + AudioFileFormat::OGG_VORBIS_320, + AudioFileFormat::MP3_320, + ], + Bitrate::Bitrate160 => [ + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::MP3_160, + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::MP3_96, + AudioFileFormat::MP3_256, + AudioFileFormat::OGG_VORBIS_320, + AudioFileFormat::MP3_320, + ], + Bitrate::Bitrate320 => [ + AudioFileFormat::OGG_VORBIS_320, + AudioFileFormat::MP3_320, + AudioFileFormat::MP3_256, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::MP3_160, + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::MP3_96, + ], + }; + + let (format, file_id) = + match formats + .iter() + .find_map(|format| match audio_item.files.get(format) { + Some(&file_id) => Some((*format, file_id)), + _ => None, + }) { + Some(t) => t, + None => { + warn!( + "<{}> is not available in any supported format", + audio_item.name + ); + return None; + } + }; + + let bytes_per_second = self.stream_data_rate(format)?; + + // This is only a loop to be able to reload the file if an error occurred // while opening a cached file. loop { - let encrypted_file = AudioFile::open( - &self.session, - file_id, - bytes_per_second, - play_from_beginning, - ); + let encrypted_file = AudioFile::open(&self.session, file_id, bytes_per_second); let encrypted_file = match encrypted_file.await { Ok(encrypted_file) => encrypted_file, Err(e) => { - error!("Unable to load encrypted file: {:?}", 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 - stream_loader_controller.set_stream_mode(); - } else { - // we need to seek -> we set stream mode after the initial seek. - stream_loader_controller.set_random_access_mode(); - } - - let key = match self.session.audio_key().request(spotify_id, file_id).await { - Ok(key) => key, + // Not all audio files are encrypted. If we can't get a key, try loading the track + // without decryption. If the file was encrypted after all, the decoder will fail + // parsing and bail out, so we should be safe from outputting ear-piercing noise. + let key = match self.session.audio_key().request(track_id, file_id).await { + Ok(key) => Some(key), Err(e) => { - error!("Unable to load decryption key: {:?}", e); - return None; + warn!("Unable to load key, continuing without decryption: {e}"); + None } }; let mut decrypted_file = AudioDecrypt::new(key, encrypted_file); - let normalisation_data = match NormalisationData::parse_from_file(&mut decrypted_file) { - Ok(data) => data, - Err(_) => { - warn!("Unable to extract normalisation data, using default value."); - NormalisationData { - track_gain_db: 0.0, - track_peak: 1.0, - album_gain_db: 0.0, - album_peak: 1.0, - } - } - }; - - let audio_file = Subfile::new(decrypted_file, 0xa7); - - let result = if self.config.passthrough { - match PassthroughDecoder::new(audio_file) { - Ok(result) => Ok(Box::new(result) as Decoder), - Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())), - } + let is_ogg_vorbis = AudioFiles::is_ogg_vorbis(format); + let (offset, mut normalisation_data) = if is_ogg_vorbis { + // Spotify stores normalisation data in a custom Ogg packet instead of Vorbis comments. + let normalisation_data = + NormalisationData::parse_from_ogg(&mut decrypted_file).ok(); + (SPOTIFY_OGG_HEADER_END, normalisation_data) } else { - match VorbisDecoder::new(audio_file) { - Ok(result) => Ok(Box::new(result) as Decoder), - Err(e) => Err(DecoderError::LewtonDecoder(e.to_string())), + (0, None) + }; + + let audio_file = match Subfile::new( + decrypted_file, + offset, + stream_loader_controller.len() as u64, + ) { + Ok(audio_file) => audio_file, + Err(e) => { + error!("PlayerTrackLoader::load_track error opening subfile: {e}"); + return None; } }; - let mut decoder = match result { + let mut symphonia_decoder = |audio_file, format| { + SymphoniaDecoder::new(audio_file, format).map(|mut decoder| { + // For formats other that Vorbis, we'll try getting normalisation data from + // ReplayGain metadata fields, if present. + if normalisation_data.is_none() { + normalisation_data = decoder.normalisation_data(); + } + Box::new(decoder) as Decoder + }) + }; + + #[cfg(feature = "passthrough-decoder")] + let decoder_type = if self.config.passthrough { + PassthroughDecoder::new(audio_file, format).map(|x| Box::new(x) as Decoder) + } else { + symphonia_decoder(audio_file, format) + }; + + #[cfg(not(feature = "passthrough-decoder"))] + let decoder_type = symphonia_decoder(audio_file, format); + + let normalisation_data = normalisation_data.unwrap_or_else(|| { + warn!("Unable to get normalisation data, continuing with defaults."); + NormalisationData::default() + }); + + let mut decoder = match decoder_type { Ok(decoder) => decoder, Err(e) if is_cached => { - warn!( - "Unable to read cached audio file: {}. Trying to download it.", - e - ); + warn!("Unable to read cached audio file: {e}. Trying to download it."); match self.session.cache() { Some(cache) => { @@ -869,29 +1140,54 @@ impl PlayerTrackLoader { continue; } Err(e) => { - error!("Unable to read audio file: {}", e); + error!("Unable to read audio file: {e}"); return None; } }; - let position_pcm = PlayerInternal::position_ms_to_pcm(position_ms); + let duration_ms = audio_item.duration_ms; + // Don't try to seek past the track's duration. + // If the position is invalid just start from + // the beginning of the track. + let position_ms = if position_ms > duration_ms { + warn!( + "Invalid start position of {position_ms} ms exceeds track's duration of {duration_ms} ms, starting track from the beginning" + ); + 0 + } else { + position_ms + }; - if position_pcm != 0 { - if let Err(e) = decoder.seek(position_pcm) { - error!("PlayerTrackLoader load_track: {}", e); + // Ensure the starting position. Even when we want to play from the beginning, + // the cursor may have been moved by parsing normalisation data. This may not + // matter for playback (but won't hurt either), but may be useful for the + // passthrough decoder. + let stream_position_ms = match decoder.seek(position_ms) { + Ok(new_position_ms) => new_position_ms, + Err(e) => { + error!( + "PlayerTrackLoader::load_track error seeking to starting position {position_ms}: {e}" + ); + return None; } - stream_loader_controller.set_stream_mode(); - } - let stream_position_pcm = position_pcm; - info!("<{}> ({} ms) loaded", audio.name, audio.duration); + }; + + // Ensure streaming mode now that we are ready to play from the requested position. + stream_loader_controller.set_stream_mode(); + + let is_explicit = audio_item.is_explicit; + + info!("<{}> ({} ms) loaded", audio_item.name, duration_ms); return Some(PlayerLoadedTrackData { decoder, normalisation_data, stream_loader_controller, + audio_item, bytes_per_second, duration_ms, - stream_position_pcm, + stream_position_ms, + is_explicit, }); } } @@ -919,58 +1215,70 @@ 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 if let PlayerState::Loading { ref mut loader, - track_id, + ref track_id, start_playback, play_request_id, } = self.state { - match loader.as_mut().poll(cx) { - Poll::Ready(Ok(loaded_track)) => { - self.start_playback( - track_id, - play_request_id, - loaded_track, - start_playback, - ); - if let PlayerState::Loading { .. } = self.state { - error!("The state wasn't changed by start_playback()"); - exit(1); + // The loader may be terminated if we are trying to load the same track + // as before, and that track failed to open before. + let track_id = track_id.clone(); + + if !loader.as_mut().is_terminated() { + match loader.as_mut().poll(cx) { + Poll::Ready(Ok(loaded_track)) => { + self.start_playback( + track_id, + play_request_id, + loaded_track, + start_playback, + ); + if let PlayerState::Loading { .. } = self.state { + error!("The state wasn't changed by start_playback()"); + exit(1); + } } + Poll::Ready(Err(e)) => { + error!( + "Skipping to next track, unable to load track <{track_id:?}>: {e:?}" + ); + self.send_event(PlayerEvent::Unavailable { + track_id, + play_request_id, + }) + } + Poll::Pending => (), } - Poll::Ready(Err(_)) => { - warn!("Unable to load <{:?}>\nSkipping to next track", track_id); - assert!(self.state.is_loading()); - self.send_event(PlayerEvent::EndOfTrack { - track_id, - play_request_id, - }) - } - Poll::Pending => (), } } // handle pending preload requests. if let PlayerPreload::Loading { ref mut loader, - track_id, + ref track_id, } = self.preload { + let track_id = track_id.clone(); match loader.as_mut().poll(cx) { Poll::Ready(Ok(loaded_track)) => { - self.send_event(PlayerEvent::Preloading { track_id }); + self.send_event(PlayerEvent::Preloading { + track_id: track_id.clone(), + }); self.preload = PlayerPreload::Ready { track_id, loaded_track: Box::new(loaded_track), }; } Poll::Ready(Err(_)) => { - debug!("Unable to preload {:?}", track_id); + debug!("Unable to preload {track_id:?}"); self.preload = PlayerPreload::None; // Let Spirc know that the track was unavailable. if let PlayerState::Playing { @@ -994,72 +1302,120 @@ impl Future for PlayerInternal { self.ensure_sink_running(); if let PlayerState::Playing { - track_id, + ref track_id, play_request_id, ref mut decoder, normalisation_factor, - ref mut stream_position_pcm, + ref mut stream_position_ms, ref mut reported_nominal_start_time, - duration_ms, .. } = self.state { + let track_id = track_id.clone(); match decoder.next_packet() { - Ok(packet) => { - if !passthrough { - if let Some(ref packet) = packet { - match packet.samples() { - Ok(samples) => { - *stream_position_pcm += - (samples.len() / NUM_CHANNELS as usize) as u64; - let stream_position_millis = - Self::position_pcm_to_ms(*stream_position_pcm); + Ok(result) => { + if let Some((ref packet_position, ref packet)) = result { + let new_stream_position_ms = packet_position.position_ms; + let expected_position_ms = std::mem::replace( + &mut *stream_position_ms, + new_stream_position_ms, + ); + if !passthrough { + match packet.samples() { + Ok(_) => { + let new_stream_position = Duration::from_millis( + new_stream_position_ms as u64, + ); + + let now = Instant::now(); + + // Only notify if we're skipped some packets *or* we are behind. + // If we're ahead it's probably due to a buffer of the backend + // and we're actually in time. let notify_about_position = match *reported_nominal_start_time { None => true, Some(reported_nominal_start_time) => { - // only notify if we're behind. If we're ahead it's probably due to a buffer of the backend and we're actually in time. - let lag = (Instant::now() - - reported_nominal_start_time) - .as_millis() - as i64 - - stream_position_millis as i64; - lag > Duration::from_secs(1).as_millis() - as i64 + let mut notify = false; + + if packet_position.skipped { + if let Some(ahead) = new_stream_position + .checked_sub(Duration::from_millis( + expected_position_ms as u64, + )) + { + notify |= + ahead >= Duration::from_secs(1) + } + } + + if let Some(lag) = now + .checked_duration_since( + reported_nominal_start_time, + ) + { + if let Some(lag) = + lag.checked_sub(new_stream_position) + { + notify |= + lag >= Duration::from_secs(1) + } + } + + notify } }; + if notify_about_position { - *reported_nominal_start_time = Some( - Instant::now() - - Duration::from_millis( - stream_position_millis as u64, - ), - ); - self.send_event(PlayerEvent::Playing { - track_id, + *reported_nominal_start_time = + now.checked_sub(new_stream_position); + self.send_event(PlayerEvent::PositionCorrection { play_request_id, - position_ms: stream_position_millis as u32, - duration_ms, + track_id: track_id.clone(), + position_ms: new_stream_position_ms, }); } + + if let Some(interval) = + self.config.position_update_interval + { + let last_progress_update_since_ms = + now.duration_since(self.last_progress_update); + + if last_progress_update_since_ms > interval { + self.last_progress_update = now; + self.send_event(PlayerEvent::PositionChanged { + play_request_id, + track_id, + position_ms: new_stream_position_ms, + }); + } + } } Err(e) => { - error!("PlayerInternal poll: {}", e); - exit(1); + error!( + "Skipping to next track, unable to decode samples for track <{track_id:?}>: {e:?}" + ); + self.send_event(PlayerEvent::EndOfTrack { + track_id, + play_request_id, + }) } } } - } else { - // position, even if irrelevant, must be set so that seek() is called - *stream_position_pcm = duration_ms.into(); } - self.handle_packet(packet, normalisation_factor); + self.handle_packet(result, normalisation_factor); } Err(e) => { - error!("PlayerInternal poll: {}", e); - exit(1); + 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 { @@ -1069,26 +1425,28 @@ impl Future for PlayerInternal { } if let PlayerState::Playing { - track_id, + ref track_id, play_request_id, duration_ms, - stream_position_pcm, + stream_position_ms, ref mut stream_loader_controller, ref mut suggested_to_preload_next_track, .. } | PlayerState::Paused { - track_id, + ref track_id, play_request_id, duration_ms, - stream_position_pcm, + stream_position_ms, ref mut stream_loader_controller, ref mut suggested_to_preload_next_track, .. } = self.state { + let track_id = track_id.clone(); + if (!*suggested_to_preload_next_track) - && ((duration_ms as i64 - Self::position_pcm_to_ms(stream_position_pcm) as i64) + && ((duration_ms as i64 - stream_position_ms as i64) < PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS as i64) && stream_loader_controller.range_to_end_available() { @@ -1100,10 +1458,6 @@ impl Future for PlayerInternal { } } - if self.session.is_invalid() { - return Poll::Ready(()); - } - if (!self.state.is_playing()) && all_futures_completed_or_not_ready { return Poll::Pending; } @@ -1112,14 +1466,6 @@ impl Future for PlayerInternal { } impl PlayerInternal { - fn position_pcm_to_ms(position_pcm: u64) -> u32 { - (position_pcm as f64 * MS_PER_PAGE) as u32 - } - - fn position_ms_to_pcm(position_ms: u32) -> u64 { - (position_ms as f64 * PAGES_PER_MS) as u64 - } - fn ensure_sink_running(&mut self) { if self.sink_status != SinkStatus::Running { trace!("== Starting sink =="); @@ -1129,8 +1475,8 @@ impl PlayerInternal { match self.sink.start() { Ok(()) => self.sink_status = SinkStatus::Running, Err(e) => { - error!("{}", e); - exit(1); + error!("{e}"); + self.handle_pause(); } } } @@ -1152,7 +1498,7 @@ impl PlayerInternal { } } Err(e) => { - error!("{}", e); + error!("{e}"); exit(1); } } @@ -1172,25 +1518,27 @@ impl PlayerInternal { fn handle_player_stop(&mut self) { match self.state { PlayerState::Playing { - track_id, + ref track_id, play_request_id, .. } | PlayerState::Paused { - track_id, + ref track_id, play_request_id, .. } | PlayerState::EndOfTrack { - track_id, + ref track_id, play_request_id, .. } | PlayerState::Loading { - track_id, + ref track_id, play_request_id, .. } => { + let track_id = track_id.clone(); + self.ensure_sink_stopped(false); self.send_event(PlayerEvent::Stopped { track_id, @@ -1200,177 +1548,174 @@ impl PlayerInternal { } PlayerState::Stopped => (), PlayerState::Invalid => { - error!("PlayerInternal handle_player_stop: invalid state"); + error!("PlayerInternal::handle_player_stop in invalid state"); exit(1); } } } fn handle_play(&mut self) { - if let PlayerState::Paused { - track_id, - play_request_id, - stream_position_pcm, - duration_ms, - .. - } = self.state - { - self.state.paused_to_playing(); - - let position_ms = Self::position_pcm_to_ms(stream_position_pcm); - self.send_event(PlayerEvent::Playing { - track_id, + match self.state { + PlayerState::Paused { + ref track_id, play_request_id, - position_ms, - duration_ms, - }); - self.ensure_sink_running(); - } else { - warn!("Player::play called from invalid state"); + stream_position_ms, + .. + } => { + let track_id = track_id.clone(); + + self.state.paused_to_playing(); + self.send_event(PlayerEvent::Playing { + track_id, + play_request_id, + position_ms: stream_position_ms, + }); + self.ensure_sink_running(); + } + PlayerState::Loading { + ref mut start_playback, + .. + } => { + *start_playback = true; + } + _ => error!("Player::play called from invalid state: {:?}", self.state), } } fn handle_pause(&mut self) { - if let PlayerState::Playing { - track_id, - play_request_id, - stream_position_pcm, - duration_ms, - .. - } = self.state - { - self.state.playing_to_paused(); - - self.ensure_sink_stopped(false); - let position_ms = Self::position_pcm_to_ms(stream_position_pcm); - self.send_event(PlayerEvent::Paused { - track_id, + match self.state { + PlayerState::Paused { .. } => self.ensure_sink_stopped(false), + PlayerState::Playing { + ref track_id, play_request_id, - position_ms, - duration_ms, - }); - } else { - warn!("Player::pause called from invalid state"); + stream_position_ms, + .. + } => { + let track_id = track_id.clone(); + + self.state.playing_to_paused(); + + self.ensure_sink_stopped(false); + self.send_event(PlayerEvent::Paused { + track_id, + play_request_id, + position_ms: stream_position_ms, + }); + } + PlayerState::Loading { + ref mut start_playback, + .. + } => { + *start_playback = false; + } + _ => error!("Player::pause called from invalid state: {:?}", self.state), } } - fn handle_packet(&mut self, packet: Option, normalisation_factor: f64) { + fn handle_packet( + &mut self, + packet: Option<(AudioPacketPosition, AudioPacket)>, + normalisation_factor: f64, + ) { match packet { - Some(mut packet) => { + Some((_, mut packet)) => { if !packet.is_empty() { if let AudioPacket::Samples(ref mut data) = packet { - if self.config.normalisation - && !(f64::abs(normalisation_factor - 1.0) <= f64::EPSILON - && self.config.normalisation_method == NormalisationMethod::Basic) - { - for sample in data.iter_mut() { - let mut actual_normalisation_factor = normalisation_factor; - if self.config.normalisation_method == NormalisationMethod::Dynamic - { - if self.limiter_active { - // "S"-shaped curve with a configurable knee during attack and release: - // - > 1.0 yields soft knees at start and end, steeper in between - // - 1.0 yields a linear function from 0-100% - // - between 0.0 and 1.0 yields hard knees at start and end, flatter in between - // - 0.0 yields a step response to 50%, causing distortion - // - Rates < 0.0 invert the limiter and are invalid - let mut shaped_limiter_strength = self.limiter_strength; - if shaped_limiter_strength > 0.0 - && shaped_limiter_strength < 1.0 - { - shaped_limiter_strength = 1.0 - / (1.0 - + f64::powf( - shaped_limiter_strength - / (1.0 - shaped_limiter_strength), - -self.config.normalisation_knee, - )); - } - actual_normalisation_factor = - (1.0 - shaped_limiter_strength) * normalisation_factor - + shaped_limiter_strength * self.limiter_factor; - }; + // Get the volume for the packet. In the case of hardware volume control + // this will always be 1.0 (no change). + let volume = self.volume_getter.attenuation_factor(); - // Cast the fields here for better readability - let normalisation_attack = - self.config.normalisation_attack.as_secs_f64(); - let normalisation_release = - self.config.normalisation_release.as_secs_f64(); - let limiter_release_counter = - self.limiter_release_counter as f64; - let limiter_attack_counter = self.limiter_attack_counter as f64; - let samples_per_second = SAMPLES_PER_SECOND as f64; - - // Always check for peaks, even when the limiter is already active. - // There may be even higher peaks than we initially targeted. - // Check against the normalisation factor that would be applied normally. - let abs_sample = f64::abs(*sample * normalisation_factor); - if abs_sample > self.config.normalisation_threshold { - self.limiter_active = true; - if self.limiter_release_counter > 0 { - // A peak was encountered while releasing the limiter; - // synchronize with the current release limiter strength. - self.limiter_attack_counter = (((samples_per_second - * normalisation_release) - - limiter_release_counter) - / (normalisation_release / normalisation_attack)) - as u32; - self.limiter_release_counter = 0; - } - - self.limiter_attack_counter = - self.limiter_attack_counter.saturating_add(1); - - self.limiter_strength = limiter_attack_counter - / (samples_per_second * normalisation_attack); - - if abs_sample > self.limiter_peak_sample { - self.limiter_peak_sample = abs_sample; - self.limiter_factor = - self.config.normalisation_threshold - / self.limiter_peak_sample; - } - } else if self.limiter_active { - if self.limiter_attack_counter > 0 { - // Release may start within the attack period, before - // the limiter reached full strength. For that reason - // start the release by synchronizing with the current - // attack limiter strength. - self.limiter_release_counter = (((samples_per_second - * normalisation_attack) - - limiter_attack_counter) - * (normalisation_release / normalisation_attack)) - as u32; - self.limiter_attack_counter = 0; - } - - self.limiter_release_counter = - self.limiter_release_counter.saturating_add(1); - - if self.limiter_release_counter - > (samples_per_second * normalisation_release) as u32 - { - self.reset_limiter(); - } else { - self.limiter_strength = ((samples_per_second - * normalisation_release) - - limiter_release_counter) - / (samples_per_second * normalisation_release); - } + // For the basic normalisation method, a normalisation factor of 1.0 + // indicates that there is nothing to normalise (all samples should pass + // unaltered). For the dynamic method, there may still be peaks that we + // want to shave off. + // + // No matter the case we apply volume attenuation last if there is any. + match (self.config.normalisation, self.config.normalisation_method) { + (false, _) => { + if volume < 1.0 { + for sample in data.iter_mut() { + *sample *= volume; } } - *sample *= actual_normalisation_factor; } - } + (true, NormalisationMethod::Dynamic) => { + // zero-cost shorthands + let threshold_db = self.config.normalisation_threshold_dbfs; + let knee_db = self.config.normalisation_knee_db; + let attack_cf = self.config.normalisation_attack_cf; + let release_cf = self.config.normalisation_release_cf; - if let Some(ref editor) = self.audio_filter { - editor.modify_stream(data) + for sample in data.iter_mut() { + // Feedforward limiter in the log domain + // After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). + // Digital Dynamic Range Compressor Design—A Tutorial and + // Analysis. Journal of The Audio Engineering Society, 60, + // 399-408. + + // This implementation assumes audio is stereo. + + // step 0: apply gain stage + *sample *= normalisation_factor; + + // step 1-4: half-wave rectification and conversion into dB, and + // gain computer with soft knee and subtractor + let limiter_db = { + // Add slight DC offset. Some samples are silence, which is + // -inf dB and gets the limiter stuck. Adding a small + // positive offset prevents this. + *sample += f64::MIN_POSITIVE; + + let bias_db = ratio_to_db(sample.abs()) - threshold_db; + let knee_boundary_db = bias_db * 2.0; + if knee_boundary_db < -knee_db { + 0.0 + } else if knee_boundary_db.abs() <= knee_db { + let term = knee_boundary_db + knee_db; + term * term * self.normalisation_knee_factor + } else { + bias_db + } + }; + + // track left/right channel + let channel = self.normalisation_channel; + self.normalisation_channel ^= 1; + + // step 5: smooth, decoupled peak detector for each channel + // Use direct references to reduce repeated array indexing + let integrator = &mut self.normalisation_integrators[channel]; + let peak = &mut self.normalisation_peaks[channel]; + + *integrator = f64::max( + limiter_db, + release_cf * *integrator + (1.0 - release_cf) * limiter_db, + ); + *peak = attack_cf * *peak + (1.0 - attack_cf) * *integrator; + + // steps 6-8: conversion into level and multiplication into gain + // stage. Find maximum peak across both channels to couple the + // gain and maintain stereo imaging. + let max_peak = f64::max( + self.normalisation_peaks[0], + self.normalisation_peaks[1], + ); + *sample *= db_to_ratio(-max_peak) * volume; + } + } + (true, NormalisationMethod::Basic) => { + if normalisation_factor < 1.0 || volume < 1.0 { + for sample in data.iter_mut() { + *sample *= normalisation_factor * volume; + } + } + } } } - if let Err(e) = self.sink.write(&packet, &mut self.converter) { - error!("{}", e); - exit(1); + if let Err(e) = self.sink.write(packet, &mut self.converter) { + error!("{e}"); + self.handle_pause(); } } } @@ -1378,13 +1723,13 @@ impl PlayerInternal { None => { self.state.playing_to_end_of_track(); if let PlayerState::EndOfTrack { - track_id, + ref track_id, play_request_id, .. } = self.state { self.send_event(PlayerEvent::EndOfTrack { - track_id, + track_id: track_id.clone(), play_request_id, }) } else { @@ -1395,23 +1740,18 @@ impl PlayerInternal { } } - fn reset_limiter(&mut self) { - self.limiter_active = false; - self.limiter_release_counter = 0; - self.limiter_attack_counter = 0; - self.limiter_peak_sample = 0.0; - self.limiter_factor = 1.0; - self.limiter_strength = 0.0; - } - fn start_playback( &mut self, - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, loaded_track: PlayerLoadedTrackData, start_playback: bool, ) { - let position_ms = Self::position_pcm_to_ms(loaded_track.stream_position_pcm); + let audio_item = Box::new(loaded_track.audio_item.clone()); + + self.send_event(PlayerEvent::TrackChanged { audio_item }); + + let position_ms = loaded_track.stream_position_ms; let mut config = self.config.clone(); if config.normalisation_type == NormalisationType::Auto { @@ -1426,94 +1766,75 @@ impl PlayerInternal { if start_playback { self.ensure_sink_running(); - self.send_event(PlayerEvent::Playing { - track_id, + track_id: track_id.clone(), play_request_id, position_ms, - duration_ms: loaded_track.duration_ms, }); self.state = PlayerState::Playing { track_id, play_request_id, decoder: loaded_track.decoder, + audio_item: loaded_track.audio_item, 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, - stream_position_pcm: loaded_track.stream_position_pcm, - reported_nominal_start_time: Some( - Instant::now() - Duration::from_millis(position_ms as u64), - ), + stream_position_ms: loaded_track.stream_position_ms, + reported_nominal_start_time: Instant::now() + .checked_sub(Duration::from_millis(position_ms as u64)), suggested_to_preload_next_track: false, + is_explicit: loaded_track.is_explicit, }; } else { self.ensure_sink_stopped(false); self.state = PlayerState::Paused { - track_id, + track_id: track_id.clone(), play_request_id, decoder: loaded_track.decoder, + audio_item: loaded_track.audio_item, 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, - stream_position_pcm: loaded_track.stream_position_pcm, + stream_position_ms: loaded_track.stream_position_ms, suggested_to_preload_next_track: false, + is_explicit: loaded_track.is_explicit, }; self.send_event(PlayerEvent::Paused { track_id, play_request_id, position_ms, - duration_ms: loaded_track.duration_ms, }); } } fn handle_command_load( &mut self, - track_id: SpotifyId, - play_request_id: u64, + track_id: SpotifyUri, + play_request_id_option: Option, play: bool, position_ms: u32, - ) { + ) -> PlayerResult { + let play_request_id = + play_request_id_option.unwrap_or(self.play_request_id_generator.get()); + + self.send_event(PlayerEvent::PlayRequestIdChanged { play_request_id }); + if !self.config.gapless { self.ensure_sink_stopped(play); } - // emit the correct player event - match self.state { - PlayerState::Playing { - track_id: old_track_id, - .. - } - | PlayerState::Paused { - track_id: old_track_id, - .. - } - | PlayerState::EndOfTrack { - track_id: old_track_id, - .. - } - | PlayerState::Loading { - track_id: old_track_id, - .. - } => self.send_event(PlayerEvent::Changed { - old_track_id, - new_track_id: track_id, - }), - PlayerState::Stopped => self.send_event(PlayerEvent::Started { - track_id, - play_request_id, - position_ms, - }), - PlayerState::Invalid { .. } => { - error!("PlayerInternal handle_command_load: invalid state"); - exit(1); - } + + if matches!(self.state, PlayerState::Invalid) { + return Err(Error::internal(format!( + "Player::handle_command_load called from invalid state: {:?}", + self.state + ))); } // Now we check at different positions whether we already have a pre-loaded version @@ -1524,68 +1845,54 @@ impl PlayerInternal { if let PlayerState::EndOfTrack { track_id: previous_track_id, .. - } = self.state + } = &self.state { - if previous_track_id == track_id { + if *previous_track_id == track_id { let mut loaded_track = match mem::replace(&mut self.state, PlayerState::Invalid) { PlayerState::EndOfTrack { loaded_track, .. } => loaded_track, _ => { - error!("PlayerInternal handle_command_load: Invalid PlayerState"); - exit(1); + return Err(Error::internal(format!( + "PlayerInternal::handle_command_load repeating the same track: invalid state: {:?}", + self.state + ))); } }; - let position_pcm = Self::position_ms_to_pcm(position_ms); - - if position_pcm != loaded_track.stream_position_pcm { - loaded_track - .stream_loader_controller - .set_random_access_mode(); - if let Err(e) = loaded_track.decoder.seek(position_pcm) { - // This may be blocking. - error!("PlayerInternal handle_command_load: {}", e); - } - loaded_track.stream_loader_controller.set_stream_mode(); - loaded_track.stream_position_pcm = position_pcm; + if position_ms != loaded_track.stream_position_ms { + // This may be blocking. + loaded_track.stream_position_ms = loaded_track.decoder.seek(position_ms)?; } self.preload = PlayerPreload::None; self.start_playback(track_id, play_request_id, loaded_track, play); if let PlayerState::Invalid = self.state { - error!("start_playback() hasn't set a valid player state."); - exit(1); + return Err(Error::internal(format!( + "PlayerInternal::handle_command_load repeating the same track: start_playback() did not transition to valid player state: {:?}", + self.state + ))); } - return; + return Ok(()); } } // Check if we are already playing the track. If so, just do a seek and update our info. if let PlayerState::Playing { - track_id: current_track_id, - ref mut stream_position_pcm, + track_id: ref current_track_id, + ref mut stream_position_ms, ref mut decoder, - ref mut stream_loader_controller, .. } | PlayerState::Paused { - track_id: current_track_id, - ref mut stream_position_pcm, + track_id: ref current_track_id, + ref mut stream_position_ms, ref mut decoder, - ref mut stream_loader_controller, .. } = self.state { - if current_track_id == track_id { + if *current_track_id == track_id { // we can use the current decoder. Ensure it's at the correct position. - let position_pcm = Self::position_ms_to_pcm(position_ms); - - if position_pcm != *stream_position_pcm { - stream_loader_controller.set_random_access_mode(); - if let Err(e) = decoder.seek(position_pcm) { - // This may be blocking. - error!("PlayerInternal handle_command_load: {}", e); - } - stream_loader_controller.set_stream_mode(); - *stream_position_pcm = position_pcm; + if position_ms != *stream_position_ms { + // This may be blocking. + *stream_position_ms = decoder.seek(position_ms)?; } // Move the info from the current state into a PlayerLoadedTrackData so we can use @@ -1593,21 +1900,25 @@ impl PlayerInternal { let old_state = mem::replace(&mut self.state, PlayerState::Invalid); if let PlayerState::Playing { - stream_position_pcm, + stream_position_ms, decoder, + audio_item, stream_loader_controller, bytes_per_second, duration_ms, normalisation_data, + is_explicit, .. } | PlayerState::Paused { - stream_position_pcm, + stream_position_ms, decoder, + audio_item, stream_loader_controller, bytes_per_second, duration_ms, normalisation_data, + is_explicit, .. } = old_state { @@ -1615,23 +1926,29 @@ impl PlayerInternal { decoder, normalisation_data, stream_loader_controller, + audio_item, bytes_per_second, duration_ms, - stream_position_pcm, + stream_position_ms, + is_explicit, }; self.preload = PlayerPreload::None; self.start_playback(track_id, play_request_id, loaded_track, play); if let PlayerState::Invalid = self.state { - error!("start_playback() hasn't set a valid player state."); - exit(1); + return Err(Error::internal(format!( + "PlayerInternal::handle_command_load already playing this track: start_playback() did not transition to valid player state: {:?}", + self.state + ))); } - return; + return Ok(()); } else { - error!("PlayerInternal handle_command_load: Invalid PlayerState"); - exit(1); + return Err(Error::internal(format!( + "PlayerInternal::handle_command_load already playing this track: invalid state: {:?}", + self.state + ))); } } } @@ -1640,42 +1957,32 @@ impl PlayerInternal { if let PlayerPreload::Ready { track_id: loaded_track_id, .. - } = self.preload + } = &self.preload { - if track_id == loaded_track_id { + if track_id == *loaded_track_id { let preload = std::mem::replace(&mut self.preload, PlayerPreload::None); if let PlayerPreload::Ready { track_id, mut loaded_track, } = preload { - let position_pcm = Self::position_ms_to_pcm(position_ms); - - if position_pcm != loaded_track.stream_position_pcm { - loaded_track - .stream_loader_controller - .set_random_access_mode(); - if let Err(e) = loaded_track.decoder.seek(position_pcm) { - // This may be blocking - error!("PlayerInternal handle_command_load: {}", e); - } - loaded_track.stream_loader_controller.set_stream_mode(); + if position_ms != loaded_track.stream_position_ms { + // This may be blocking + loaded_track.stream_position_ms = loaded_track.decoder.seek(position_ms)?; } self.start_playback(track_id, play_request_id, *loaded_track, play); - return; + return Ok(()); } else { - error!("PlayerInternal handle_command_load: Invalid PlayerState"); - exit(1); + return Err(Error::internal(format!( + "PlayerInternal::handle_command_loading preloaded track: invalid state: {:?}", + self.state + ))); } } } - // We need to load the track - either from scratch or by completing a preload. - // In any case we go into a Loading state to load the track. - self.ensure_sink_stopped(play); - self.send_event(PlayerEvent::Loading { - track_id, + track_id: track_id.clone(), play_request_id, position_ms, }); @@ -1684,9 +1991,9 @@ impl PlayerInternal { let loader = if let PlayerPreload::Loading { track_id: loaded_track_id, .. - } = self.preload + } = &self.preload { - if (track_id == loaded_track_id) && (position_ms == 0) { + if (track_id == *loaded_track_id) && (position_ms == 0) { let mut preload = PlayerPreload::None; std::mem::swap(&mut preload, &mut self.preload); if let PlayerPreload::Loading { loader, .. } = preload { @@ -1704,7 +2011,8 @@ impl PlayerInternal { self.preload = PlayerPreload::None; // If we don't have a loader yet, create one from scratch. - let loader = loader.unwrap_or_else(|| Box::pin(self.load_track(track_id, position_ms))); + let loader = + loader.unwrap_or_else(|| Box::pin(self.load_track(track_id.clone(), position_ms))); // Set ourselves to a loading state. self.state = PlayerState::Loading { @@ -1713,9 +2021,11 @@ impl PlayerInternal { start_playback: play, loader, }; + + Ok(()) } - fn handle_command_preload(&mut self, track_id: SpotifyId) { + fn handle_command_preload(&mut self, track_id: SpotifyUri) { debug!("Preloading track"); let mut preload_track = true; // check whether the track is already loaded somewhere or being loaded. @@ -1726,9 +2036,9 @@ impl PlayerInternal { | PlayerPreload::Ready { track_id: currently_loading, .. - } = self.preload + } = &self.preload { - if currently_loading == track_id { + if *currently_loading == track_id { // we're already preloading the requested track. preload_track = false; } else { @@ -1748,9 +2058,9 @@ impl PlayerInternal { | PlayerState::EndOfTrack { track_id: current_track_id, .. - } = self.state + } = &self.state { - if current_track_id == track_id { + if *current_track_id == track_id { // we already have the requested track loaded. preload_track = false; } @@ -1758,7 +2068,7 @@ impl PlayerInternal { // schedule the preload of the current track if desired. if preload_track { - let loader = self.load_track(track_id, 0); + let loader = self.load_track(track_id.clone(), 0); self.preload = PlayerPreload::Loading { track_id, loader: Box::pin(loader), @@ -1766,87 +2076,84 @@ impl PlayerInternal { } } - fn handle_command_seek(&mut self, position_ms: u32) { - if let Some(stream_loader_controller) = self.state.stream_loader_controller() { - stream_loader_controller.set_random_access_mode(); + fn handle_command_seek(&mut self, position_ms: u32) -> PlayerResult { + // When we are still loading, the user may immediately ask to + // seek to another position yet the decoder won't be ready for + // that. In this case just restart the loading process but + // with the requested position. + if let PlayerState::Loading { + ref track_id, + play_request_id, + start_playback, + .. + } = self.state + { + return self.handle_command_load( + track_id.clone(), + Some(play_request_id), + start_playback, + position_ms, + ); } - if let Some(decoder) = self.state.decoder() { - let position_pcm = Self::position_ms_to_pcm(position_ms); - match decoder.seek(position_pcm) { - Ok(_) => { + if let Some(decoder) = self.state.decoder() { + match decoder.seek(position_ms) { + Ok(new_position_ms) => { if let PlayerState::Playing { - ref mut stream_position_pcm, + ref mut stream_position_ms, + ref track_id, + play_request_id, .. } | PlayerState::Paused { - ref mut stream_position_pcm, + ref mut stream_position_ms, + ref track_id, + play_request_id, .. } = self.state { - *stream_position_pcm = position_pcm; + *stream_position_ms = new_position_ms; + + self.send_event(PlayerEvent::Seeked { + play_request_id, + track_id: track_id.clone(), + position_ms: new_position_ms, + }); } } - Err(e) => error!("PlayerInternal handle_command_seek: {}", e), + Err(e) => error!("PlayerInternal::handle_command_seek error: {e}"), } } else { - warn!("Player::seek called from invalid state"); - } - - // If we're playing, ensure, that we have enough data leaded to avoid a buffer underrun. - if let Some(stream_loader_controller) = self.state.stream_loader_controller() { - stream_loader_controller.set_stream_mode(); + error!("Player::seek called from invalid state: {:?}", self.state); } // ensure we have a bit of a buffer of downloaded data - self.preload_data_before_playback(); + self.preload_data_before_playback()?; if let PlayerState::Playing { - track_id, - play_request_id, ref mut reported_nominal_start_time, - duration_ms, .. } = self.state { *reported_nominal_start_time = - Some(Instant::now() - Duration::from_millis(position_ms as u64)); - self.send_event(PlayerEvent::Playing { - track_id, - play_request_id, - position_ms, - duration_ms, - }); - } - if let PlayerState::Paused { - track_id, - play_request_id, - duration_ms, - .. - } = self.state - { - self.send_event(PlayerEvent::Paused { - track_id, - play_request_id, - position_ms, - duration_ms, - }); + Instant::now().checked_sub(Duration::from_millis(position_ms as u64)); } + + Ok(()) } - fn handle_command(&mut self, cmd: PlayerCommand) { - debug!("command={:?}", cmd); + fn handle_command(&mut self, cmd: PlayerCommand) -> PlayerResult { + debug!("command={cmd:?}"); match cmd { PlayerCommand::Load { track_id, - play_request_id, play, position_ms, - } => self.handle_command_load(track_id, play_request_id, play, position_ms), + } => self.handle_command_load(track_id, None, play, position_ms)?, PlayerCommand::Preload { track_id } => self.handle_command_preload(track_id), - PlayerCommand::Seek(position_ms) => self.handle_command_seek(position_ms), + PlayerCommand::Seek(position_ms) => self.handle_command_seek(position_ms)?, PlayerCommand::Play => self.handle_play(), @@ -1854,37 +2161,106 @@ impl PlayerInternal { PlayerCommand::Stop => self.handle_player_stop(), + PlayerCommand::SetSession(session) => self.session = session, + PlayerCommand::AddEventSender(sender) => self.event_senders.push(sender), PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback, - PlayerCommand::EmitVolumeSetEvent(volume) => { - self.send_event(PlayerEvent::VolumeSet { volume }) + PlayerCommand::EmitVolumeChangedEvent(volume) => { + self.send_event(PlayerEvent::VolumeChanged { volume }) } + PlayerCommand::EmitRepeatChangedEvent { context, track } => { + self.send_event(PlayerEvent::RepeatChanged { context, track }) + } + + PlayerCommand::EmitShuffleChangedEvent(shuffle) => { + self.send_event(PlayerEvent::ShuffleChanged { shuffle }) + } + + PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => { + self.send_event(PlayerEvent::AutoPlayChanged { auto_play }) + } + + PlayerCommand::EmitSessionClientChangedEvent { + client_id, + client_name, + client_brand_name, + client_model_name, + } => self.send_event(PlayerEvent::SessionClientChanged { + client_id, + client_name, + client_brand_name, + client_model_name, + }), + + PlayerCommand::EmitSessionConnectedEvent { + connection_id, + user_name, + } => self.send_event(PlayerEvent::SessionConnected { + connection_id, + user_name, + }), + + PlayerCommand::EmitSessionDisconnectedEvent { + connection_id, + user_name, + } => self.send_event(PlayerEvent::SessionDisconnected { + connection_id, + user_name, + }), + PlayerCommand::SetAutoNormaliseAsAlbum(setting) => { self.auto_normalise_as_album = setting } - } + + PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => { + self.send_event(PlayerEvent::FilterExplicitContentChanged { filter }); + + if filter { + if let PlayerState::Playing { + ref track_id, + play_request_id, + is_explicit, + .. + } + | PlayerState::Paused { + ref track_id, + play_request_id, + is_explicit, + .. + } = self.state + { + let track_id = track_id.clone(); + + if is_explicit { + warn!( + "Currently loaded track is explicit, which client setting forbids -- skipping to next track." + ); + self.send_event(PlayerEvent::EndOfTrack { + track_id, + play_request_id, + }) + } + } + } + } + }; + + Ok(()) } fn send_event(&mut self, event: PlayerEvent) { - let mut index = 0; - while index < self.event_senders.len() { - match self.event_senders[index].send(event.clone()) { - Ok(_) => index += 1, - Err(_) => { - self.event_senders.remove(index); - } - } - } + self.event_senders + .retain(|sender| sender.send(event.clone()).is_ok()); } fn load_track( - &self, - spotify_id: SpotifyId, + &mut self, + spotify_uri: SpotifyUri, position_ms: u32, - ) -> impl Future> + Send + 'static { + ) -> impl FusedFuture> + Send + 'static { // This method creates a future that returns the loaded stream and associated info. // Ideally all work should be done using asynchronous code. However, seek() on the // audio stream is implemented in a blocking fashion. Thus, we can't turn it into future @@ -1898,53 +2274,71 @@ impl PlayerInternal { let (result_tx, result_rx) = oneshot::channel(); - std::thread::spawn(move || { - let data = futures_executor::block_on(loader.load_track(spotify_id, position_ms)); + let load_handles_clone = self.load_handles.clone(); + let handle = tokio::runtime::Handle::current(); + + let load_handle = thread::spawn(move || { + let data = handle.block_on(loader.load_track(spotify_uri, position_ms)); if let Some(data) = data { let _ = result_tx.send(data); } + + let mut load_handles = load_handles_clone.lock().expect(LOAD_HANDLES_POISON_MSG); + load_handles.remove(&thread::current().id()); }); + let mut load_handles = self.load_handles.lock().expect(LOAD_HANDLES_POISON_MSG); + load_handles.insert(load_handle.thread().id(), load_handle); + result_rx.map_err(|_| ()) } - fn preload_data_before_playback(&mut self) { + fn preload_data_before_playback(&mut self) -> PlayerResult { if let PlayerState::Playing { bytes_per_second, ref mut stream_loader_controller, .. } = self.state { + let read_ahead_during_playback = AudioFetchParams::get().read_ahead_during_playback; // Request our read ahead range - let request_data_length = max( - (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS - * stream_loader_controller.ping_time().as_secs_f32() - * bytes_per_second as f32) as usize, - (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, - ); - stream_loader_controller.fetch_next(request_data_length); + let request_data_length = + (read_ahead_during_playback.as_secs_f32() * bytes_per_second as f32) as usize; - // Request the part we want to wait for blocking. This effecively means we wait for the previous request to partially complete. - let wait_for_data_length = max( - (READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS - * stream_loader_controller.ping_time().as_secs_f32() - * bytes_per_second as f32) as usize, - (READ_AHEAD_BEFORE_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, - ); - stream_loader_controller.fetch_next_blocking(wait_for_data_length); + // Request the part we want to wait for blocking. This effectively means we wait for the previous request to partially complete. + let wait_for_data_length = + (read_ahead_during_playback.as_secs_f32() * bytes_per_second as f32) as usize; + + stream_loader_controller.fetch_next_and_wait(request_data_length, wait_for_data_length) + } else { + Ok(()) } } } impl Drop for PlayerInternal { fn drop(&mut self) { - debug!("drop PlayerInternal[{}]", self.session.session_id()); + debug!("drop PlayerInternal[{}]", self.player_id); + + let handles: Vec> = { + // waiting for the thread while holding the mutex would result in a deadlock + let mut load_handles = self.load_handles.lock().expect(LOAD_HANDLES_POISON_MSG); + + load_handles + .drain() + .map(|(_thread_id, handle)| handle) + .collect() + }; + + for handle in handles { + let _ = handle.join(); + } } } -impl ::std::fmt::Debug for PlayerCommand { - fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { - match *self { +impl fmt::Debug for PlayerCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { PlayerCommand::Load { track_id, play, @@ -1963,25 +2357,72 @@ impl ::std::fmt::Debug for PlayerCommand { PlayerCommand::Pause => f.debug_tuple("Pause").finish(), PlayerCommand::Stop => f.debug_tuple("Stop").finish(), PlayerCommand::Seek(position) => f.debug_tuple("Seek").field(&position).finish(), + PlayerCommand::SetSession(_) => f.debug_tuple("SetSession").finish(), PlayerCommand::AddEventSender(_) => f.debug_tuple("AddEventSender").finish(), PlayerCommand::SetSinkEventCallback(_) => { f.debug_tuple("SetSinkEventCallback").finish() } - PlayerCommand::EmitVolumeSetEvent(volume) => { - f.debug_tuple("VolumeSet").field(&volume).finish() - } + PlayerCommand::EmitVolumeChangedEvent(volume) => f + .debug_tuple("EmitVolumeChangedEvent") + .field(&volume) + .finish(), PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f .debug_tuple("SetAutoNormaliseAsAlbum") .field(&setting) .finish(), + PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => f + .debug_tuple("EmitFilterExplicitContentChangedEvent") + .field(&filter) + .finish(), + PlayerCommand::EmitSessionConnectedEvent { + connection_id, + user_name, + } => f + .debug_tuple("EmitSessionConnectedEvent") + .field(&connection_id) + .field(&user_name) + .finish(), + PlayerCommand::EmitSessionDisconnectedEvent { + connection_id, + user_name, + } => f + .debug_tuple("EmitSessionDisconnectedEvent") + .field(&connection_id) + .field(&user_name) + .finish(), + PlayerCommand::EmitSessionClientChangedEvent { + client_id, + client_name, + client_brand_name, + client_model_name, + } => f + .debug_tuple("EmitSessionClientChangedEvent") + .field(&client_id) + .field(&client_name) + .field(&client_brand_name) + .field(&client_model_name) + .finish(), + PlayerCommand::EmitShuffleChangedEvent(shuffle) => f + .debug_tuple("EmitShuffleChangedEvent") + .field(&shuffle) + .finish(), + PlayerCommand::EmitRepeatChangedEvent { context, track } => f + .debug_tuple("EmitRepeatChangedEvent") + .field(&context) + .field(&track) + .finish(), + PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f + .debug_tuple("EmitAutoPlayChangedEvent") + .field(&auto_play) + .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 { + match self { Stopped => f.debug_struct("Stopped").finish(), Loading { track_id, @@ -2023,17 +2464,23 @@ impl ::std::fmt::Debug for PlayerState { } } } + struct Subfile { stream: T, offset: u64, + length: u64, } impl Subfile { - pub fn new(mut stream: T, offset: u64) -> Subfile { - if let Err(e) = stream.seek(SeekFrom::Start(offset)) { - error!("Subfile new Error: {}", e); - } - Subfile { stream, offset } + pub fn new(mut stream: T, offset: u64, length: u64) -> Result, io::Error> { + let target = SeekFrom::Start(offset); + stream.seek(target)?; + + Ok(Subfile { + stream, + offset, + length, + }) } } @@ -2044,17 +2491,35 @@ impl Read for Subfile { } impl Seek for Subfile { - fn seek(&mut self, mut pos: SeekFrom) -> io::Result { - pos = match pos { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let pos = match pos { SeekFrom::Start(offset) => SeekFrom::Start(offset + self.offset), - x => x, + SeekFrom::End(offset) => { + if (self.length as i64 - offset) < self.offset as i64 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "newpos would be < self.offset", + )); + } + pos + } + _ => pos, }; let newpos = self.stream.seek(pos)?; - if newpos > self.offset { - Ok(newpos - self.offset) - } else { - Ok(0) - } + Ok(newpos - self.offset) + } +} + +impl MediaSource for Subfile +where + R: Read + Seek + Send + Sync, +{ + fn is_seekable(&self) -> bool { + true + } + + fn byte_len(&self) -> Option { + Some(self.length) } } diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index 38f76371..1d880e4b 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -1,16 +1,16 @@ [package] name = "librespot-protocol" -version = "0.3.1" +version.workspace = true +rust-version.workspace = true authors = ["Paul Liétar "] -build = "build.rs" +license.workspace = true description = "The protobuf logic for communicating with Spotify servers" -license = "MIT" -repository = "https://github.com/librespot-org/librespot" -edition = "2018" +repository.workspace = true +edition.workspace = true +build = "build.rs" [dependencies] -protobuf = "2.14.0" +protobuf = "3" [build-dependencies] -protobuf-codegen-pure = "2.14.0" -glob = "0.3.0" +protobuf-codegen = "3" diff --git a/protocol/build.rs b/protocol/build.rs index c65c109a..a20ea22d 100644 --- a/protocol/build.rs +++ b/protocol/build.rs @@ -9,24 +9,60 @@ fn out_dir() -> PathBuf { } fn cleanup() { - let _ = fs::remove_dir_all(&out_dir()); + let _ = fs::remove_dir_all(out_dir()); } 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("media.proto"), + proto_dir.join("connectivity.proto"), + proto_dir.join("devices.proto"), + proto_dir.join("entity_extension_data.proto"), + proto_dir.join("extended_metadata.proto"), + proto_dir.join("extension_kind.proto"), + proto_dir.join("metadata.proto"), + proto_dir.join("player.proto"), + proto_dir.join("playlist_annotate3.proto"), + proto_dir.join("playlist_permission.proto"), + proto_dir.join("playlist4_external.proto"), + proto_dir.join("lens-model.proto"), + proto_dir.join("signal-model.proto"), + proto_dir.join("spotify/clienttoken/v0/clienttoken_http.proto"), + proto_dir.join("spotify/login5/v3/challenges/code.proto"), + proto_dir.join("spotify/login5/v3/challenges/hashcash.proto"), + proto_dir.join("spotify/login5/v3/client_info.proto"), + proto_dir.join("spotify/login5/v3/credentials/credentials.proto"), + proto_dir.join("spotify/login5/v3/identifiers/identifiers.proto"), + proto_dir.join("spotify/login5/v3/login5.proto"), + proto_dir.join("spotify/login5/v3/user_info.proto"), + proto_dir.join("storage-resolve.proto"), + proto_dir.join("user_attributes.proto"), + proto_dir.join("autoplay_context_request.proto"), + proto_dir.join("social_connect_v2.proto"), + proto_dir.join("transfer_state.proto"), + proto_dir.join("context_player_options.proto"), + proto_dir.join("playback.proto"), + proto_dir.join("play_history.proto"), + proto_dir.join("session.proto"), + proto_dir.join("queue.proto"), + proto_dir.join("context_track.proto"), + proto_dir.join("context.proto"), + proto_dir.join("restrictions.proto"), + proto_dir.join("context_page.proto"), + proto_dir.join("play_origin.proto"), + proto_dir.join("suppressions.proto"), + proto_dir.join("instrumentation_params.proto"), + // TODO: remove these legacy protobufs when we are on the new API completely proto_dir.join("authentication.proto"), + proto_dir.join("canvaz.proto"), + proto_dir.join("canvaz-meta.proto"), + proto_dir.join("explicit_content_pubsub.proto"), proto_dir.join("keyexchange.proto"), proto_dir.join("mercury.proto"), - proto_dir.join("metadata.proto"), - proto_dir.join("playlist4changes.proto"), - proto_dir.join("playlist4content.proto"), - proto_dir.join("playlist4issues.proto"), - proto_dir.join("playlist4meta.proto"), - proto_dir.join("playlist4ops.proto"), proto_dir.join("pubsub.proto"), - proto_dir.join("spirc.proto"), ]; let slices = files.iter().map(Deref::deref).collect::>(); @@ -34,7 +70,8 @@ fn compile() { let out_dir = out_dir(); fs::create_dir(&out_dir).expect("create_dir"); - protobuf_codegen_pure::Codegen::new() + protobuf_codegen::Codegen::new() + .pure() .out_dir(&out_dir) .inputs(&slices) .include(&proto_dir) @@ -42,26 +79,7 @@ fn compile() { .expect("Codegen failed."); } -fn generate_mod_rs() { - let out_dir = out_dir(); - - let mods = glob::glob(&out_dir.join("*.rs").to_string_lossy()) - .expect("glob") - .filter_map(|p| { - p.ok() - .map(|p| format!("pub mod {};", p.file_stem().unwrap().to_string_lossy())) - }) - .collect::>() - .join("\n"); - - let mod_rs = out_dir.join("mod.rs"); - fs::write(&mod_rs, format!("// @generated\n{}\n", mods)).expect("write"); - - println!("cargo:rustc-env=PROTO_MOD_RS={}", mod_rs.to_string_lossy()); -} - fn main() { cleanup(); compile(); - generate_mod_rs(); } diff --git a/protocol/proto/AdContext.proto b/protocol/proto/AdContext.proto new file mode 100644 index 00000000..ba56bd00 --- /dev/null +++ b/protocol/proto/AdContext.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdContext { + optional string preceding_content_uri = 1; + optional string preceding_playback_id = 2; + optional int32 preceding_end_position = 3; + repeated string ad_ids = 4; + optional string ad_request_id = 5; + optional string succeeding_content_uri = 6; + optional string succeeding_playback_id = 7; + optional int32 succeeding_start_position = 8; + optional int32 preceding_duration = 9; +} diff --git a/protocol/proto/AdDecisionEvent.proto b/protocol/proto/AdDecisionEvent.proto new file mode 100644 index 00000000..07a0a940 --- /dev/null +++ b/protocol/proto/AdDecisionEvent.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdDecisionEvent { + optional string request_id = 1; + optional string decision_request_id = 2; + optional string decision_type = 3; +} diff --git a/protocol/proto/AdError.proto b/protocol/proto/AdError.proto new file mode 100644 index 00000000..1a69e788 --- /dev/null +++ b/protocol/proto/AdError.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdError { + optional string request_type = 1; + optional string error_message = 2; + optional int64 http_error_code = 3; + optional string request_url = 4; + optional string tracking_event = 5; +} diff --git a/protocol/proto/AdEvent.proto b/protocol/proto/AdEvent.proto new file mode 100644 index 00000000..69cf82bb --- /dev/null +++ b/protocol/proto/AdEvent.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdEvent { + optional string request_id = 1; + optional string app_startup_id = 2; + optional string ad_id = 3; + optional string lineitem_id = 4; + optional string creative_id = 5; + optional string slot = 6; + optional string format = 7; + optional string type = 8; + optional bool skippable = 9; + optional string event = 10; + optional string event_source = 11; + optional string event_reason = 12; + optional int32 event_sequence_num = 13; + optional int32 position = 14; + optional int32 duration = 15; + optional bool in_focus = 16; + optional float volume = 17; + optional string product_name = 18; +} diff --git a/protocol/proto/AdRequestEvent.proto b/protocol/proto/AdRequestEvent.proto new file mode 100644 index 00000000..3ffdf863 --- /dev/null +++ b/protocol/proto/AdRequestEvent.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdRequestEvent { + optional string feature_identifier = 1; + optional string requested_ad_type = 2; + optional int64 latency_ms = 3; + repeated string requested_ad_types = 4; +} diff --git a/protocol/proto/AdSlotEvent.proto b/protocol/proto/AdSlotEvent.proto new file mode 100644 index 00000000..1f345b69 --- /dev/null +++ b/protocol/proto/AdSlotEvent.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AdSlotEvent { + optional string event = 1; + optional string ad_id = 2; + optional string lineitem_id = 3; + optional string creative_id = 4; + optional string slot = 5; + optional string format = 6; + optional bool in_focus = 7; + optional string app_startup_id = 8; + optional string request_id = 9; +} diff --git a/protocol/proto/AmazonWakeUpTime.proto b/protocol/proto/AmazonWakeUpTime.proto new file mode 100644 index 00000000..25d64c48 --- /dev/null +++ b/protocol/proto/AmazonWakeUpTime.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AmazonWakeUpTime { + optional int64 delay_to_online = 1; +} diff --git a/protocol/proto/AudioDriverError.proto b/protocol/proto/AudioDriverError.proto new file mode 100644 index 00000000..3c97b461 --- /dev/null +++ b/protocol/proto/AudioDriverError.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioDriverError { + optional int64 error_code = 1; + optional string location = 2; + optional string driver_name = 3; + optional string additional_data = 4; +} diff --git a/protocol/proto/AudioDriverInfo.proto b/protocol/proto/AudioDriverInfo.proto new file mode 100644 index 00000000..23bae0a7 --- /dev/null +++ b/protocol/proto/AudioDriverInfo.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioDriverInfo { + optional string driver_name = 1; + optional string output_device_name = 2; + optional string output_device_category = 3; + optional string reason = 4; +} diff --git a/protocol/proto/AudioFileSelection.proto b/protocol/proto/AudioFileSelection.proto new file mode 100644 index 00000000..d99b36f4 --- /dev/null +++ b/protocol/proto/AudioFileSelection.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioFileSelection { + optional bytes playback_id = 1; + optional string strategy_name = 2; + optional int64 bitrate = 3; + optional bytes predict_id = 4; + optional string file_origin = 5; + optional int32 target_bitrate = 6; +} diff --git a/protocol/proto/AudioOffliningSettingsReport.proto b/protocol/proto/AudioOffliningSettingsReport.proto new file mode 100644 index 00000000..71d87f17 --- /dev/null +++ b/protocol/proto/AudioOffliningSettingsReport.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioOffliningSettingsReport { + optional string default_sync_bitrate_product_state = 1; + optional int64 user_selected_sync_bitrate = 2; + optional int64 sync_bitrate = 3; + optional bool sync_over_cellular = 4; + optional string primary_resource_type = 5; +} diff --git a/protocol/proto/AudioRateLimit.proto b/protocol/proto/AudioRateLimit.proto new file mode 100644 index 00000000..0ead830d --- /dev/null +++ b/protocol/proto/AudioRateLimit.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioRateLimit { + optional string driver_name = 1; + optional string output_device_name = 2; + optional string output_device_category = 3; + optional int64 max_size = 4; + optional int64 refill_per_milliseconds = 5; + optional int64 frames_requested = 6; + optional int64 frames_acquired = 7; + optional bytes playback_id = 8; +} diff --git a/protocol/proto/AudioSessionEvent.proto b/protocol/proto/AudioSessionEvent.proto new file mode 100644 index 00000000..c9b1a531 --- /dev/null +++ b/protocol/proto/AudioSessionEvent.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioSessionEvent { + optional string event = 1; + optional string context = 2; + optional string json_data = 3; +} diff --git a/protocol/proto/AudioSettingsReport.proto b/protocol/proto/AudioSettingsReport.proto new file mode 100644 index 00000000..e99ea8ec --- /dev/null +++ b/protocol/proto/AudioSettingsReport.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioSettingsReport { + optional bool offline_mode = 1; + optional string default_play_bitrate_product_state = 2; + optional int64 user_selected_bitrate = 3; + optional int64 play_bitrate = 4; + optional bool low_bitrate_on_cellular = 5; + optional string default_sync_bitrate_product_state = 6; + optional int64 user_selected_sync_bitrate = 7; + optional int64 sync_bitrate = 8; + optional bool sync_over_cellular = 9; + optional string enable_gapless_product_state = 10; + optional bool enable_gapless = 11; + optional string enable_crossfade_product_state = 12; + optional bool enable_crossfade = 13; + optional int64 crossfade_time = 14; + optional bool enable_normalization = 15; + optional int64 playback_speed = 16; + optional string audio_loudness_level = 17; + optional bool enable_automix = 18; + optional bool enable_silence_trimmer = 19; + optional bool enable_mono_downmixer = 20; +} diff --git a/protocol/proto/AudioStreamingSettingsReport.proto b/protocol/proto/AudioStreamingSettingsReport.proto new file mode 100644 index 00000000..ef6e4730 --- /dev/null +++ b/protocol/proto/AudioStreamingSettingsReport.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message AudioStreamingSettingsReport { + optional string default_play_bitrate_product_state = 1; + optional int64 user_selected_play_bitrate_cellular = 2; + optional int64 user_selected_play_bitrate_wifi = 3; + optional int64 play_bitrate_cellular = 4; + optional int64 play_bitrate_wifi = 5; + optional bool allow_downgrade = 6; +} diff --git a/protocol/proto/BoomboxPlaybackInstrumentation.proto b/protocol/proto/BoomboxPlaybackInstrumentation.proto new file mode 100644 index 00000000..01e3f2c7 --- /dev/null +++ b/protocol/proto/BoomboxPlaybackInstrumentation.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message BoomboxPlaybackInstrumentation { + optional bytes playback_id = 1; + optional bool was_playback_paused = 2; + repeated string dimensions = 3; + map total_buffer_size = 4; + map number_of_calls = 5; + map total_duration = 6; + map first_call_time = 7; + map last_call_time = 8; +} diff --git a/protocol/proto/BrokenObject.proto b/protocol/proto/BrokenObject.proto new file mode 100644 index 00000000..3bdb6677 --- /dev/null +++ b/protocol/proto/BrokenObject.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message BrokenObject { + optional string type = 1; + optional string id = 2; + optional int64 error_code = 3; + optional bytes playback_id = 4; +} diff --git a/protocol/proto/CacheError.proto b/protocol/proto/CacheError.proto new file mode 100644 index 00000000..ad85c342 --- /dev/null +++ b/protocol/proto/CacheError.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CacheError { + optional int64 error_code = 1; + optional int64 os_error_code = 2; + optional string realm = 3; + optional bytes file_id = 4; + optional int64 num_errors = 5; + optional string cache_path = 6; + optional int64 size = 7; + optional int64 range_start = 8; + optional int64 range_end = 9; +} diff --git a/protocol/proto/CachePruningReport.proto b/protocol/proto/CachePruningReport.proto new file mode 100644 index 00000000..3225f1d5 --- /dev/null +++ b/protocol/proto/CachePruningReport.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CachePruningReport { + optional bytes cache_id = 1; + optional int64 time_spent_pruning_ms = 2; + optional int64 size_before_prune_kb = 3; + optional int64 size_after_prune_kb = 4; + optional int64 num_entries_pruned = 5; + optional int64 num_entries_pruned_expired = 6; + optional int64 size_entries_pruned_expired_kb = 7; + optional int64 num_entries_pruned_limit = 8; + optional int64 size_pruned_limit_kb = 9; + optional int64 num_entries_pruned_never_used = 10; + optional int64 size_pruned_never_used_kb = 11; + optional int64 num_entries_pruned_max_realm_size = 12; + optional int64 size_pruned_max_realm_size_kb = 13; + optional int64 num_entries_pruned_min_free_space = 14; + optional int64 size_pruned_min_free_space_kb = 15; +} diff --git a/protocol/proto/CacheRealmPruningReport.proto b/protocol/proto/CacheRealmPruningReport.proto new file mode 100644 index 00000000..479a26a5 --- /dev/null +++ b/protocol/proto/CacheRealmPruningReport.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CacheRealmPruningReport { + optional bytes cache_id = 1; + optional int64 realm_id = 2; + optional int64 num_entries_pruned = 3; + optional int64 num_entries_pruned_expired = 4; + optional int64 size_entries_pruned_expired_kb = 5; + optional int64 num_entries_pruned_limit = 6; + optional int64 size_pruned_limit_kb = 7; + optional int64 num_entries_pruned_never_used = 8; + optional int64 size_pruned_never_used_kb = 9; + optional int64 num_entries_pruned_max_realm_size = 10; + optional int64 size_pruned_max_realm_size_kb = 11; + optional int64 num_entries_pruned_min_free_space = 12; + optional int64 size_pruned_min_free_space_kb = 13; +} diff --git a/protocol/proto/CacheRealmReport.proto b/protocol/proto/CacheRealmReport.proto new file mode 100644 index 00000000..4d3c8a55 --- /dev/null +++ b/protocol/proto/CacheRealmReport.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CacheRealmReport { + optional bytes cache_id = 1; + optional int64 realm_id = 2; + optional int64 num_entries = 3; + optional int64 num_locked_entries = 4; + optional int64 num_locked_entries_current_user = 5; + optional int64 num_full_entries = 6; + optional int64 size_kb = 7; + optional int64 locked_size_kb = 8; +} diff --git a/protocol/proto/CacheReport.proto b/protocol/proto/CacheReport.proto new file mode 100644 index 00000000..ac034059 --- /dev/null +++ b/protocol/proto/CacheReport.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CacheReport { + optional bytes cache_id = 1; + optional string cache_path = 21; + optional string volatile_path = 22; + optional int64 max_cache_size = 2; + optional int64 free_space = 3; + optional int64 total_space = 4; + optional int64 cache_age = 5; + optional int64 num_users_with_locked_entries = 6; + optional int64 permanent_files = 7; + optional int64 permanent_size_kb = 8; + optional int64 unknown_permanent_files = 9; + optional int64 unknown_permanent_size_kb = 10; + optional int64 volatile_files = 11; + optional int64 volatile_size_kb = 12; + optional int64 unknown_volatile_files = 13; + optional int64 unknown_volatile_size_kb = 14; + optional int64 num_entries = 15; + optional int64 num_locked_entries = 16; + optional int64 num_locked_entries_current_user = 17; + optional int64 num_full_entries = 18; + optional int64 size_kb = 19; + optional int64 locked_size_kb = 20; +} diff --git a/protocol/proto/ClientLocale.proto b/protocol/proto/ClientLocale.proto new file mode 100644 index 00000000..a8e330b3 --- /dev/null +++ b/protocol/proto/ClientLocale.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ClientLocale { + optional string client_default_locale = 1; + optional string user_specified_locale = 2; +} diff --git a/protocol/proto/ColdStartupSequence.proto b/protocol/proto/ColdStartupSequence.proto new file mode 100644 index 00000000..cfeedee9 --- /dev/null +++ b/protocol/proto/ColdStartupSequence.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ColdStartupSequence { + optional string terminal_state = 1; + map steps = 2; + map metadata = 3; + optional string connection_type = 4; + optional string initial_application_state = 5; + optional string terminal_application_state = 6; + optional string view_load_sequence_id = 7; + optional int32 device_year_class = 8; + map subdurations = 9; +} diff --git a/protocol/proto/CollectionLevelDbInfo.proto b/protocol/proto/CollectionLevelDbInfo.proto new file mode 100644 index 00000000..4f222487 --- /dev/null +++ b/protocol/proto/CollectionLevelDbInfo.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CollectionLevelDbInfo { + optional string bucket = 1; + optional bool use_leveldb = 2; + optional bool migration_from_file_ok = 3; + optional bool index_check_ok = 4; + optional bool leveldb_works = 5; + optional bool already_migrated = 6; +} diff --git a/protocol/proto/CollectionOfflineControllerEmptyTrackList.proto b/protocol/proto/CollectionOfflineControllerEmptyTrackList.proto new file mode 100644 index 00000000..ee830433 --- /dev/null +++ b/protocol/proto/CollectionOfflineControllerEmptyTrackList.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message CollectionOfflineControllerEmptyTrackList { + optional string link_type = 1; + optional bool consistent_with_collection = 2; + optional int64 collection_size = 3; +} diff --git a/protocol/proto/ConfigurationApplied.proto b/protocol/proto/ConfigurationApplied.proto new file mode 100644 index 00000000..40aad33c --- /dev/null +++ b/protocol/proto/ConfigurationApplied.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConfigurationApplied { + optional int64 last_rcs_fetch_time = 1; + optional string installation_id = 2; + repeated int32 policy_group_ids = 3; + optional string configuration_assignment_id = 4; + optional string rc_client_id = 5; + optional string rc_client_version = 6; + optional string platform = 7; + optional string fetch_type = 8; +} diff --git a/protocol/proto/ConfigurationFetched.proto b/protocol/proto/ConfigurationFetched.proto new file mode 100644 index 00000000..bb61a2e0 --- /dev/null +++ b/protocol/proto/ConfigurationFetched.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConfigurationFetched { + optional int64 last_rcs_fetch_time = 1; + optional string installation_id = 2; + optional string configuration_assignment_id = 3; + optional string property_set_id = 4; + optional string attributes_set_id = 5; + optional string rc_client_id = 6; + optional string rc_client_version = 7; + optional string rc_sdk_version = 8; + optional string platform = 9; + optional string fetch_type = 10; + optional int64 latency = 11; + optional int64 payload_size = 12; + optional int32 status_code = 13; + optional string error_reason = 14; + optional string error_message = 15; + optional string error_reason_configuration_resolve = 16; + optional string error_message_configuration_resolve = 17; + optional string error_reason_account_attributes = 18; + optional string error_message_account_attributes = 19; + optional int32 error_code_account_attributes = 20; + optional int32 error_code_configuration_resolve = 21; +} diff --git a/protocol/proto/ConfigurationFetchedNonAuth.proto b/protocol/proto/ConfigurationFetchedNonAuth.proto new file mode 100644 index 00000000..e28d1d39 --- /dev/null +++ b/protocol/proto/ConfigurationFetchedNonAuth.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConfigurationFetchedNonAuth { + optional int64 last_rcs_fetch_time = 1; + optional string installation_id = 2; + optional string configuration_assignment_id = 3; + optional string property_set_id = 4; + optional string attributes_set_id = 5; + optional string rc_client_id = 6; + optional string rc_client_version = 7; + optional string rc_sdk_version = 8; + optional string platform = 9; + optional string fetch_type = 10; + optional int64 latency = 11; + optional int64 payload_size = 12; + optional int32 status_code = 13; + optional string error_reason = 14; + optional string error_message = 15; + optional string error_reason_configuration_resolve = 16; + optional string error_message_configuration_resolve = 17; + optional string error_reason_account_attributes = 18; + optional string error_message_account_attributes = 19; + optional int32 error_code_account_attributes = 20; + optional int32 error_code_configuration_resolve = 21; +} diff --git a/protocol/proto/ConnectCredentialsRequest.proto b/protocol/proto/ConnectCredentialsRequest.proto new file mode 100644 index 00000000..d3e91cf3 --- /dev/null +++ b/protocol/proto/ConnectCredentialsRequest.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectCredentialsRequest { + optional string token_type = 1; + optional string client_id = 2; +} diff --git a/protocol/proto/ConnectDeviceDiscovered.proto b/protocol/proto/ConnectDeviceDiscovered.proto new file mode 100644 index 00000000..bb156ff7 --- /dev/null +++ b/protocol/proto/ConnectDeviceDiscovered.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectDeviceDiscovered { + optional string device_id = 1; + optional string discover_method = 2; + optional string discovered_device_id = 3; + optional string discovered_device_type = 4; + optional string discovered_library_version = 5; + optional string discovered_brand_display_name = 6; + optional string discovered_model_display_name = 7; + optional string discovered_client_id = 8; + optional string discovered_product_id = 9; + optional string discovered_device_availablilty = 10; + optional string discovered_device_public_key = 11; + optional bool capabilities_resolved = 12; +} diff --git a/protocol/proto/ConnectDialError.proto b/protocol/proto/ConnectDialError.proto new file mode 100644 index 00000000..90a8f36a --- /dev/null +++ b/protocol/proto/ConnectDialError.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectDialError { + optional string type = 1; + optional string request = 2; + optional string response = 3; + optional int64 error = 4; + optional string context = 5; +} diff --git a/protocol/proto/ConnectMdnsPacketParseError.proto b/protocol/proto/ConnectMdnsPacketParseError.proto new file mode 100644 index 00000000..e7685828 --- /dev/null +++ b/protocol/proto/ConnectMdnsPacketParseError.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectMdnsPacketParseError { + optional string type = 1; + optional string buffer = 2; + optional string ttl = 3; + optional string txt = 4; + optional string host = 5; + optional string discovery_name = 6; + optional string context = 7; +} diff --git a/protocol/proto/ConnectPullFailure.proto b/protocol/proto/ConnectPullFailure.proto new file mode 100644 index 00000000..fc1f9819 --- /dev/null +++ b/protocol/proto/ConnectPullFailure.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectPullFailure { + optional bytes transfer_data = 1; + optional int64 error_code = 2; + map reasons = 3; +} diff --git a/protocol/proto/ConnectTransferResult.proto b/protocol/proto/ConnectTransferResult.proto new file mode 100644 index 00000000..9239e845 --- /dev/null +++ b/protocol/proto/ConnectTransferResult.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectTransferResult { + optional string result = 1; + optional string device_type = 2; + optional string discovery_class = 3; + optional string device_model = 4; + optional string device_brand = 5; + optional string device_software_version = 6; + optional int64 duration = 7; + optional string device_client_id = 8; + optional string transfer_intent_id = 9; + optional string transfer_debug_log = 10; + optional string error_code = 11; + optional int32 http_response_code = 12; + optional string initial_device_state = 13; + optional int32 retry_count = 14; + optional int32 login_retry_count = 15; + optional int64 login_duration = 16; + optional string target_device_id = 17; + optional bool target_device_is_local = 18; + optional string final_device_state = 19; +} diff --git a/protocol/proto/ConnectionError.proto b/protocol/proto/ConnectionError.proto new file mode 100644 index 00000000..8c1c35bd --- /dev/null +++ b/protocol/proto/ConnectionError.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectionError { + optional int64 error_code = 1; + optional string ap = 2; + optional string proxy = 3; +} diff --git a/protocol/proto/ConnectionInfo.proto b/protocol/proto/ConnectionInfo.proto new file mode 100644 index 00000000..2c830ed5 --- /dev/null +++ b/protocol/proto/ConnectionInfo.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectionInfo { + optional string ap = 1; + optional string proxy = 2; + optional bool user_initated_login = 3; + optional string reachability_type = 4; + optional string web_installer_unique_id = 5; + optional string ap_resolve_source = 6; + optional string address_type = 7; + optional bool ipv6_failed = 8; +} diff --git a/protocol/proto/ConnectionStateChange.proto b/protocol/proto/ConnectionStateChange.proto new file mode 100644 index 00000000..28e517c0 --- /dev/null +++ b/protocol/proto/ConnectionStateChange.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ConnectionStateChange { + optional string type = 1; + optional string old = 2; + optional string new = 3; +} diff --git a/protocol/proto/DefaultConfigurationApplied.proto b/protocol/proto/DefaultConfigurationApplied.proto new file mode 100644 index 00000000..9236ecb9 --- /dev/null +++ b/protocol/proto/DefaultConfigurationApplied.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DefaultConfigurationApplied { + optional string installation_id = 1; + optional string configuration_assignment_id = 2; + optional string rc_client_id = 3; + optional string rc_client_version = 4; + optional string platform = 5; + optional string fetch_type = 6; + optional string reason = 7; +} diff --git a/protocol/proto/DesktopAuthenticationFailureNonAuth.proto b/protocol/proto/DesktopAuthenticationFailureNonAuth.proto new file mode 100644 index 00000000..e3b495ec --- /dev/null +++ b/protocol/proto/DesktopAuthenticationFailureNonAuth.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopAuthenticationFailureNonAuth { + optional string action_hash = 1; + optional string error_category = 2; + optional int32 error_code = 3; +} diff --git a/protocol/proto/DesktopAuthenticationSuccess.proto b/protocol/proto/DesktopAuthenticationSuccess.proto new file mode 100644 index 00000000..8814df79 --- /dev/null +++ b/protocol/proto/DesktopAuthenticationSuccess.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopAuthenticationSuccess { + optional string action_hash = 1; +} diff --git a/protocol/proto/DesktopDeviceInformation.proto b/protocol/proto/DesktopDeviceInformation.proto new file mode 100644 index 00000000..be503177 --- /dev/null +++ b/protocol/proto/DesktopDeviceInformation.proto @@ -0,0 +1,106 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopDeviceInformation { + optional string os_platform = 1; + optional string os_version = 2; + optional string computer_manufacturer = 3; + optional string mac_computer_model = 4; + optional string mac_computer_model_family = 5; + optional bool computer_has_internal_battery = 6; + optional bool computer_is_currently_running_on_battery_power = 7; + optional string mac_cpu_product_name = 8; + optional int64 mac_cpu_family_code = 9; + optional int64 cpu_num_physical_cores = 10; + optional int64 cpu_num_logical_cores = 11; + optional int64 cpu_clock_frequency_herz = 12; + optional int64 cpu_level_1_cache_size_bytes = 13; + optional int64 cpu_level_2_cache_size_bytes = 14; + optional int64 cpu_level_3_cache_size_bytes = 15; + optional bool cpu_is_64_bit_capable = 16; + optional int64 computer_ram_size_bytes = 17; + optional int64 computer_ram_speed_herz = 18; + optional int64 num_graphics_cards = 19; + optional int64 num_connected_screens = 20; + optional string app_screen_model_name = 21; + optional double app_screen_width_logical_points = 22; + optional double app_screen_height_logical_points = 23; + optional double mac_app_screen_scale_factor = 24; + optional double app_screen_physical_size_inches = 25; + optional int64 app_screen_bits_per_pixel = 26; + optional bool app_screen_supports_dci_p3_color_gamut = 27; + optional bool app_screen_is_built_in = 28; + optional string app_screen_graphics_card_model = 29; + optional int64 app_screen_graphics_card_vram_size_bytes = 30; + optional bool mac_app_screen_currently_contains_the_dock = 31; + optional bool mac_app_screen_currently_contains_active_menu_bar = 32; + optional bool boot_disk_is_known_ssd = 33; + optional string mac_boot_disk_connection_type = 34; + optional int64 boot_disk_capacity_bytes = 35; + optional int64 boot_disk_free_space_bytes = 36; + optional bool application_disk_is_same_as_boot_disk = 37; + optional bool application_disk_is_known_ssd = 38; + optional string mac_application_disk_connection_type = 39; + optional int64 application_disk_capacity_bytes = 40; + optional int64 application_disk_free_space_bytes = 41; + optional bool application_cache_disk_is_same_as_boot_disk = 42; + optional bool application_cache_disk_is_known_ssd = 43; + optional string mac_application_cache_disk_connection_type = 44; + optional int64 application_cache_disk_capacity_bytes = 45; + optional int64 application_cache_disk_free_space_bytes = 46; + optional bool has_pointing_device = 47; + optional bool has_builtin_pointing_device = 48; + optional bool has_touchpad = 49; + optional bool has_keyboard = 50; + optional bool has_builtin_keyboard = 51; + optional bool mac_has_touch_bar = 52; + optional bool has_touch_screen = 53; + optional bool has_pen_input = 54; + optional bool has_game_controller = 55; + optional bool has_bluetooth_support = 56; + optional int64 bluetooth_link_manager_version = 57; + optional string bluetooth_version_string = 58; + optional int64 num_audio_output_devices = 59; + optional string default_audio_output_device_name = 60; + optional string default_audio_output_device_manufacturer = 61; + optional double default_audio_output_device_current_sample_rate = 62; + optional int64 default_audio_output_device_current_bit_depth = 63; + optional int64 default_audio_output_device_current_buffer_size = 64; + optional int64 default_audio_output_device_current_num_channels = 65; + optional double default_audio_output_device_maximum_sample_rate = 66; + optional int64 default_audio_output_device_maximum_bit_depth = 67; + optional int64 default_audio_output_device_maximum_num_channels = 68; + optional bool default_audio_output_device_is_builtin = 69; + optional bool default_audio_output_device_is_virtual = 70; + optional string mac_default_audio_output_device_transport_type = 71; + optional string mac_default_audio_output_device_terminal_type = 72; + optional int64 num_video_capture_devices = 73; + optional string default_video_capture_device_manufacturer = 74; + optional string default_video_capture_device_model = 75; + optional string default_video_capture_device_name = 76; + optional int64 default_video_capture_device_image_width = 77; + optional int64 default_video_capture_device_image_height = 78; + optional string mac_default_video_capture_device_transport_type = 79; + optional bool default_video_capture_device_is_builtin = 80; + optional int64 num_active_network_interfaces = 81; + optional string mac_main_network_interface_name = 82; + optional string mac_main_network_interface_type = 83; + optional bool main_network_interface_supports_ipv4 = 84; + optional bool main_network_interface_supports_ipv6 = 85; + optional string main_network_interface_hardware_vendor = 86; + optional string main_network_interface_hardware_model = 87; + optional int64 main_network_interface_medium_speed_bps = 88; + optional int64 main_network_interface_link_speed_bps = 89; + optional double system_up_time_including_sleep_seconds = 90; + optional double system_up_time_awake_seconds = 91; + optional double app_up_time_including_sleep_seconds = 92; + optional string system_user_preferred_language_code = 93; + optional string system_user_preferred_locale = 94; + optional string mac_app_system_localization_language = 95; + optional string app_localization_language = 96; +} diff --git a/protocol/proto/DesktopGPUAccelerationInfo.proto b/protocol/proto/DesktopGPUAccelerationInfo.proto new file mode 100644 index 00000000..2fbaed08 --- /dev/null +++ b/protocol/proto/DesktopGPUAccelerationInfo.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopGPUAccelerationInfo { + optional bool is_enabled = 1; +} diff --git a/protocol/proto/DesktopHighMemoryUsage.proto b/protocol/proto/DesktopHighMemoryUsage.proto new file mode 100644 index 00000000..e55106e3 --- /dev/null +++ b/protocol/proto/DesktopHighMemoryUsage.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopHighMemoryUsage { + optional bool is_continuation_event = 1; + optional double sample_time_interval_seconds = 2; + optional int64 win_committed_bytes = 3; + optional int64 win_peak_committed_bytes = 4; + optional int64 win_working_set_bytes = 5; + optional int64 win_peak_working_set_bytes = 6; + optional int64 mac_virtual_size_bytes = 7; + optional int64 mac_resident_size_bytes = 8; + optional int64 mac_footprint_bytes = 9; +} diff --git a/protocol/proto/DesktopPerformanceIssue.proto b/protocol/proto/DesktopPerformanceIssue.proto new file mode 100644 index 00000000..4e70b435 --- /dev/null +++ b/protocol/proto/DesktopPerformanceIssue.proto @@ -0,0 +1,88 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopPerformanceIssue { + optional string event_type = 1; + optional bool is_continuation_event = 2; + optional double sample_time_interval_seconds = 3; + optional string computer_platform = 4; + optional double last_seen_main_thread_latency_seconds = 5; + optional double last_seen_core_thread_latency_seconds = 6; + optional double total_spotify_processes_cpu_load_percent = 7; + optional double main_process_cpu_load_percent = 8; + optional int64 mac_main_process_vm_size_bytes = 9; + optional int64 mac_main_process_resident_size_bytes = 10; + optional double mac_main_process_num_page_faults_per_second = 11; + optional double mac_main_process_num_pageins_per_second = 12; + optional double mac_main_process_num_cow_faults_per_second = 13; + optional double mac_main_process_num_context_switches_per_second = 14; + optional int64 main_process_num_total_threads = 15; + optional int64 main_process_num_running_threads = 16; + optional double renderer_process_cpu_load_percent = 17; + optional int64 mac_renderer_process_vm_size_bytes = 18; + optional int64 mac_renderer_process_resident_size_bytes = 19; + optional double mac_renderer_process_num_page_faults_per_second = 20; + optional double mac_renderer_process_num_pageins_per_second = 21; + optional double mac_renderer_process_num_cow_faults_per_second = 22; + optional double mac_renderer_process_num_context_switches_per_second = 23; + optional int64 renderer_process_num_total_threads = 24; + optional int64 renderer_process_num_running_threads = 25; + optional double system_total_cpu_load_percent = 26; + optional int64 mac_system_total_free_memory_size_bytes = 27; + optional int64 mac_system_total_active_memory_size_bytes = 28; + optional int64 mac_system_total_inactive_memory_size_bytes = 29; + optional int64 mac_system_total_wired_memory_size_bytes = 30; + optional int64 mac_system_total_compressed_memory_size_bytes = 31; + optional double mac_system_current_num_pageins_per_second = 32; + optional double mac_system_current_num_pageouts_per_second = 33; + optional double mac_system_current_num_page_faults_per_second = 34; + optional double mac_system_current_num_cow_faults_per_second = 35; + optional int64 system_current_num_total_processes = 36; + optional int64 system_current_num_total_threads = 37; + optional int64 computer_boot_disk_free_space_bytes = 38; + optional int64 application_disk_free_space_bytes = 39; + optional int64 application_cache_disk_free_space_bytes = 40; + optional bool computer_is_currently_running_on_battery_power = 41; + optional double computer_remaining_battery_capacity_percent = 42; + optional double computer_estimated_remaining_battery_time_seconds = 43; + optional int64 mac_computer_num_available_logical_cpu_cores_due_to_power_management = 44; + optional double mac_computer_current_processor_speed_percent_due_to_power_management = 45; + optional double mac_computer_current_cpu_time_limit_percent_due_to_power_management = 46; + optional double app_screen_width_points = 47; + optional double app_screen_height_points = 48; + optional double mac_app_screen_scale_factor = 49; + optional int64 app_screen_bits_per_pixel = 50; + optional bool app_screen_supports_dci_p3_color_gamut = 51; + optional bool app_screen_is_built_in = 52; + optional string app_screen_graphics_card_model = 53; + optional int64 app_screen_graphics_card_vram_size_bytes = 54; + optional double app_window_width_points = 55; + optional double app_window_height_points = 56; + optional double app_window_percentage_on_screen = 57; + optional double app_window_percentage_non_obscured = 58; + optional double system_up_time_including_sleep_seconds = 59; + optional double system_up_time_awake_seconds = 60; + optional double app_up_time_including_sleep_seconds = 61; + optional double computer_time_since_last_sleep_start_seconds = 62; + optional double computer_time_since_last_sleep_end_seconds = 63; + optional bool mac_system_user_session_is_currently_active = 64; + optional double mac_system_time_since_last_user_session_deactivation_seconds = 65; + optional double mac_system_time_since_last_user_session_reactivation_seconds = 66; + optional bool application_is_currently_active = 67; + optional bool application_window_is_currently_visible = 68; + optional bool mac_application_window_is_currently_minimized = 69; + optional bool application_window_is_currently_fullscreen = 70; + optional bool mac_application_is_currently_hidden = 71; + optional bool application_user_is_currently_logged_in = 72; + optional double application_time_since_last_user_log_in = 73; + optional double application_time_since_last_user_log_out = 74; + optional bool application_is_playing_now = 75; + optional string application_currently_playing_type = 76; + optional string application_currently_playing_uri = 77; + optional string application_currently_playing_ad_id = 78; +} diff --git a/protocol/proto/DesktopUpdateDownloadComplete.proto b/protocol/proto/DesktopUpdateDownloadComplete.proto new file mode 100644 index 00000000..bf1fe4d9 --- /dev/null +++ b/protocol/proto/DesktopUpdateDownloadComplete.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateDownloadComplete { + optional int64 revision = 1; + optional bool is_critical = 2; + optional string source = 3; + optional bool is_successful = 4; + optional bool is_employee = 5; +} diff --git a/protocol/proto/DesktopUpdateDownloadError.proto b/protocol/proto/DesktopUpdateDownloadError.proto new file mode 100644 index 00000000..8385d4a1 --- /dev/null +++ b/protocol/proto/DesktopUpdateDownloadError.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateDownloadError { + optional int64 revision = 1; + optional bool is_critical = 2; + optional string error_message = 3; + optional string source = 4; + optional bool is_employee = 5; +} diff --git a/protocol/proto/DesktopUpdateMessageAction.proto b/protocol/proto/DesktopUpdateMessageAction.proto new file mode 100644 index 00000000..3ff5efea --- /dev/null +++ b/protocol/proto/DesktopUpdateMessageAction.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateMessageAction { + optional bool will_download = 1; + optional int64 this_message_from_revision = 2; + optional int64 this_message_to_revision = 3; + optional bool is_critical = 4; + optional int64 already_downloaded_from_revision = 5; + optional int64 already_downloaded_to_revision = 6; + optional string source = 7; + optional bool is_employee = 8; +} diff --git a/protocol/proto/DesktopUpdateMessageProcessed.proto b/protocol/proto/DesktopUpdateMessageProcessed.proto new file mode 100644 index 00000000..71b2e766 --- /dev/null +++ b/protocol/proto/DesktopUpdateMessageProcessed.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateMessageProcessed { + optional bool success = 1; + optional string source = 2; + optional int64 revision = 3; + optional bool is_critical = 4; + optional string binary_hash = 5; + optional bool is_employee = 6; +} diff --git a/protocol/proto/DesktopUpdateResponse.proto b/protocol/proto/DesktopUpdateResponse.proto new file mode 100644 index 00000000..683672f2 --- /dev/null +++ b/protocol/proto/DesktopUpdateResponse.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DesktopUpdateResponse { + optional int64 status_code = 1; + optional int64 request_time_ms = 2; + optional int64 payload_size = 3; + optional bool is_employee = 4; + optional string error_message = 5; +} diff --git a/protocol/proto/Download.proto b/protocol/proto/Download.proto new file mode 100644 index 00000000..0b3faee9 --- /dev/null +++ b/protocol/proto/Download.proto @@ -0,0 +1,55 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Download { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional int64 bytes_from_ap = 3; + optional int64 waste_from_ap = 4; + optional int64 reqs_from_ap = 5; + optional int64 error_from_ap = 6; + optional int64 bytes_from_cdn = 7; + optional int64 waste_from_cdn = 8; + optional int64 bytes_from_cache = 9; + optional int64 content_size = 10; + optional string content_type = 11; + optional int64 ap_initial_latency = 12; + optional int64 ap_max_latency = 13; + optional int64 ap_min_latency = 14; + optional double ap_avg_latency = 15; + optional int64 ap_median_latency = 16; + optional double ap_avg_bw = 17; + optional int64 cdn_initial_latency = 18; + optional int64 cdn_max_latency = 19; + optional int64 cdn_min_latency = 20; + optional double cdn_avg_latency = 21; + optional int64 cdn_median_latency = 22; + optional int64 cdn_64k_initial_latency = 23; + optional int64 cdn_64k_max_latency = 24; + optional int64 cdn_64k_min_latency = 25; + optional double cdn_64k_avg_latency = 26; + optional int64 cdn_64k_median_latency = 27; + optional double cdn_avg_bw = 28; + optional double cdn_initial_bw_estimate = 29; + optional string cdn_uri_scheme = 30; + optional string cdn_domain = 31; + optional string cdn_socket_reuse = 32; + optional int64 num_cache_error = 33; + optional int64 bytes_from_carrier = 34; + optional int64 bytes_from_unknown = 35; + optional int64 bytes_from_wifi = 36; + optional int64 bytes_from_ethernet = 37; + optional string request_type = 38; + optional int64 total_time = 39; + optional int64 bitrate = 40; + optional int64 reqs_from_cdn = 41; + optional int64 error_from_cdn = 42; + optional string file_origin = 43; + optional string initial_disk_state = 44; + optional bool locked = 45; +} diff --git a/protocol/proto/DrmRequestFailure.proto b/protocol/proto/DrmRequestFailure.proto new file mode 100644 index 00000000..8f7df231 --- /dev/null +++ b/protocol/proto/DrmRequestFailure.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DrmRequestFailure { + optional string reason = 1; + optional int64 error_code = 2; + optional bool fatal = 3; + optional bytes playback_id = 4; +} diff --git a/protocol/proto/EndAd.proto b/protocol/proto/EndAd.proto new file mode 100644 index 00000000..cff0b7b6 --- /dev/null +++ b/protocol/proto/EndAd.proto @@ -0,0 +1,34 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EndAd { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional bytes song_id = 3; + optional string source_start = 4; + optional string reason_start = 5; + optional string source_end = 6; + optional string reason_end = 7; + optional int64 bytes_played = 8; + optional int64 bytes_in_song = 9; + optional int64 ms_played = 10; + optional int64 ms_total_est = 11; + optional int64 ms_rcv_latency = 12; + optional int64 n_seekback = 13; + optional int64 ms_seekback = 14; + optional int64 n_seekfwd = 15; + optional int64 ms_seekfwd = 16; + optional int64 ms_latency = 17; + optional int64 n_stutter = 18; + optional int64 p_lowbuffer = 19; + optional bool skipped = 20; + optional bool ad_clicked = 21; + optional string token = 22; + optional int64 client_ad_count = 23; + optional int64 client_campaign_count = 24; +} diff --git a/protocol/proto/EventSenderInternalErrorNonAuth.proto b/protocol/proto/EventSenderInternalErrorNonAuth.proto new file mode 100644 index 00000000..e6fe182a --- /dev/null +++ b/protocol/proto/EventSenderInternalErrorNonAuth.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventSenderInternalErrorNonAuth { + optional string error_message = 1; + optional string error_type = 2; + optional string error_context = 3; + optional int32 error_code = 4; +} diff --git a/protocol/proto/EventSenderStats.proto b/protocol/proto/EventSenderStats.proto new file mode 100644 index 00000000..88be6fe1 --- /dev/null +++ b/protocol/proto/EventSenderStats.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventSenderStats { + map storage_size = 1; + map sequence_number_min = 2; + map sequence_number_next = 3; +} diff --git a/protocol/proto/EventSenderStats2NonAuth.proto b/protocol/proto/EventSenderStats2NonAuth.proto new file mode 100644 index 00000000..e55eaa66 --- /dev/null +++ b/protocol/proto/EventSenderStats2NonAuth.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventSenderStats2NonAuth { + repeated bytes sequence_ids = 1; + repeated string event_names = 2; + repeated int32 loss_stats_num_entries_per_sequence_id = 3; + repeated int32 loss_stats_event_name_index = 4; + repeated int64 loss_stats_storage_sizes = 5; + repeated int64 loss_stats_sequence_number_mins = 6; + repeated int64 loss_stats_sequence_number_nexts = 7; + repeated int32 ratelimiter_stats_event_name_index = 8; + repeated int64 ratelimiter_stats_drop_count = 9; + repeated int32 drop_list_num_entries_per_sequence_id = 10; + repeated int32 drop_list_event_name_index = 11; + repeated int64 drop_list_counts_total = 12; + repeated int64 drop_list_counts_unreported = 13; +} diff --git a/protocol/proto/ExternalDeviceInfo.proto b/protocol/proto/ExternalDeviceInfo.proto new file mode 100644 index 00000000..f590df22 --- /dev/null +++ b/protocol/proto/ExternalDeviceInfo.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ExternalDeviceInfo { + optional string type = 1; + optional string subtype = 2; + optional string reason = 3; + optional bool taken_over = 4; + optional int64 num_tracks = 5; + optional int64 num_purchased_tracks = 6; + optional int64 num_playlists = 7; + optional string error = 8; + optional bool full = 9; + optional bool sync_all = 10; +} diff --git a/protocol/proto/GetInfoFailures.proto b/protocol/proto/GetInfoFailures.proto new file mode 100644 index 00000000..868ae5b7 --- /dev/null +++ b/protocol/proto/GetInfoFailures.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message GetInfoFailures { + optional string device_id = 1; + optional int64 error_code = 2; + optional string request = 3; + optional string response_body = 4; + optional string context = 5; +} diff --git a/protocol/proto/HeadFileDownload.proto b/protocol/proto/HeadFileDownload.proto new file mode 100644 index 00000000..b0d72794 --- /dev/null +++ b/protocol/proto/HeadFileDownload.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message HeadFileDownload { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional string cdn_uri_scheme = 3; + optional string cdn_domain = 4; + optional int64 head_file_size = 5; + optional int64 bytes_downloaded = 6; + optional int64 bytes_wasted = 7; + optional int64 http_latency = 8; + optional int64 http_64k_latency = 9; + optional int64 total_time = 10; + optional int64 http_result = 11; + optional int64 error_code = 12; + optional int64 cached_bytes = 13; + optional int64 bytes_from_cache = 14; + optional string socket_reuse = 15; + optional string request_type = 16; + optional string initial_disk_state = 17; +} diff --git a/protocol/proto/LegacyEndSong.proto b/protocol/proto/LegacyEndSong.proto new file mode 100644 index 00000000..9366f18d --- /dev/null +++ b/protocol/proto/LegacyEndSong.proto @@ -0,0 +1,62 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LegacyEndSong { + optional int64 sequence_number = 1; + optional string sequence_id = 2; + optional bytes playback_id = 3; + optional bytes parent_playback_id = 4; + optional string source_start = 5; + optional string reason_start = 6; + optional string source_end = 7; + optional string reason_end = 8; + optional int64 bytes_played = 9; + optional int64 bytes_in_song = 10; + optional int64 ms_played = 11; + optional int64 ms_nominal_played = 12; + optional int64 ms_total_est = 13; + optional int64 ms_rcv_latency = 14; + optional int64 ms_overlapping = 15; + optional int64 n_seekback = 16; + optional int64 ms_seekback = 17; + optional int64 n_seekfwd = 18; + optional int64 ms_seekfwd = 19; + optional int64 ms_latency = 20; + optional int64 ui_latency = 21; + optional string player_id = 22; + optional int64 ms_key_latency = 23; + optional bool offline_key = 24; + optional bool cached_key = 25; + optional int64 n_stutter = 26; + optional int64 p_lowbuffer = 27; + optional bool shuffle = 28; + optional int64 max_continous = 29; + optional int64 union_played = 30; + optional int64 artificial_delay = 31; + optional int64 bitrate = 32; + optional string play_context = 33; + optional string audiocodec = 34; + optional string play_track = 35; + optional string display_track = 36; + optional bool offline = 37; + optional int64 offline_timestamp = 38; + optional bool incognito_mode = 39; + optional string provider = 40; + optional string referer = 41; + optional string referrer_version = 42; + optional string referrer_vendor = 43; + optional string transition = 44; + optional string streaming_rule = 45; + optional string gaia_dev_id = 46; + optional string accepted_tc = 47; + optional string promotion_type = 48; + optional string page_instance_id = 49; + optional string interaction_id = 50; + optional string parent_play_track = 51; + optional int64 core_version = 52; +} diff --git a/protocol/proto/LocalFileSyncError.proto b/protocol/proto/LocalFileSyncError.proto new file mode 100644 index 00000000..0403dba1 --- /dev/null +++ b/protocol/proto/LocalFileSyncError.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFileSyncError { + optional string error = 1; +} diff --git a/protocol/proto/LocalFilesError.proto b/protocol/proto/LocalFilesError.proto new file mode 100644 index 00000000..f49d805f --- /dev/null +++ b/protocol/proto/LocalFilesError.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFilesError { + optional int64 error_code = 1; + optional string context = 2; + optional string info = 3; +} diff --git a/protocol/proto/LocalFilesImport.proto b/protocol/proto/LocalFilesImport.proto new file mode 100644 index 00000000..4674e721 --- /dev/null +++ b/protocol/proto/LocalFilesImport.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFilesImport { + optional int64 tracks = 1; + optional int64 duplicate_tracks = 2; + optional int64 failed_tracks = 3; + optional int64 matched_tracks = 4; + optional string source = 5; + optional int64 invalid_tracks = 6; +} diff --git a/protocol/proto/LocalFilesReport.proto b/protocol/proto/LocalFilesReport.proto new file mode 100644 index 00000000..cd5c99d7 --- /dev/null +++ b/protocol/proto/LocalFilesReport.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFilesReport { + optional int64 total_tracks = 1; + optional int64 total_size = 2; + optional int64 owned_tracks = 3; + optional int64 owned_size = 4; + optional int64 tracks_not_found = 5; + optional int64 tracks_bad_format = 6; + optional int64 tracks_drm_protected = 7; + optional int64 tracks_unknown_pruned = 8; + optional int64 tracks_reallocated_repaired = 9; + optional int64 enabled_sources = 10; +} diff --git a/protocol/proto/LocalFilesSourceReport.proto b/protocol/proto/LocalFilesSourceReport.proto new file mode 100644 index 00000000..9dbd4bd9 --- /dev/null +++ b/protocol/proto/LocalFilesSourceReport.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message LocalFilesSourceReport { + optional string id = 1; + optional int64 tracks = 2; +} diff --git a/protocol/proto/MdnsLoginFailures.proto b/protocol/proto/MdnsLoginFailures.proto new file mode 100644 index 00000000..cd036561 --- /dev/null +++ b/protocol/proto/MdnsLoginFailures.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message MdnsLoginFailures { + optional string device_id = 1; + optional int64 error_code = 2; + optional string response_body = 3; + optional string request = 4; + optional int64 esdk_internal_error_code = 5; + optional string context = 6; +} diff --git a/protocol/proto/MetadataExtensionClientStatistic.proto b/protocol/proto/MetadataExtensionClientStatistic.proto new file mode 100644 index 00000000..253e0e18 --- /dev/null +++ b/protocol/proto/MetadataExtensionClientStatistic.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message MetadataExtensionClientStatistic { + optional bytes task_id = 1; + optional string feature_id = 2; + optional bool is_online_param = 3; + optional int32 num_extensions_with_etags = 4; + optional int32 num_extensions_requested = 5; + optional int32 num_extensions_needed = 6; + optional int32 num_uris_requested = 7; + optional int32 num_uris_needed = 8; + optional int32 num_prepared_requests = 9; + optional int32 num_sent_requests = 10; +} diff --git a/protocol/proto/Offline2ClientError.proto b/protocol/proto/Offline2ClientError.proto new file mode 100644 index 00000000..55c9ca24 --- /dev/null +++ b/protocol/proto/Offline2ClientError.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Offline2ClientError { + optional string error = 1; + optional string device_id = 2; + optional string cache_id = 3; +} diff --git a/protocol/proto/Offline2ClientEvent.proto b/protocol/proto/Offline2ClientEvent.proto new file mode 100644 index 00000000..b45bfd59 --- /dev/null +++ b/protocol/proto/Offline2ClientEvent.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Offline2ClientEvent { + optional string event = 1; + optional string device_id = 2; + optional string cache_id = 3; +} diff --git a/protocol/proto/OfflineError.proto b/protocol/proto/OfflineError.proto new file mode 100644 index 00000000..e669ce43 --- /dev/null +++ b/protocol/proto/OfflineError.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message OfflineError { + optional int64 error_code = 1; + optional string track = 2; +} diff --git a/protocol/proto/OfflineEvent.proto b/protocol/proto/OfflineEvent.proto new file mode 100644 index 00000000..e924f093 --- /dev/null +++ b/protocol/proto/OfflineEvent.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message OfflineEvent { + optional string event = 1; + optional string data = 2; +} diff --git a/protocol/proto/OfflineReport.proto b/protocol/proto/OfflineReport.proto new file mode 100644 index 00000000..2835f77d --- /dev/null +++ b/protocol/proto/OfflineReport.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message OfflineReport { + optional int64 total_num_tracks = 1; + optional int64 num_downloaded_tracks = 2; + optional int64 num_downloaded_tracks_keyless = 3; + optional int64 total_num_links = 4; + optional int64 total_num_links_keyless = 5; + map context_num_links_map = 6; + map linktype_num_tracks_map = 7; + optional int64 track_limit = 8; + optional int64 expiry = 9; + optional string change_reason = 10; + optional int64 offline_keys = 11; + optional int64 cached_keys = 12; + optional int64 total_num_episodes = 13; + optional int64 num_downloaded_episodes = 14; + optional int64 episode_limit = 15; + optional int64 episode_expiry = 16; +} diff --git a/protocol/proto/PlaybackError.proto b/protocol/proto/PlaybackError.proto new file mode 100644 index 00000000..6897490e --- /dev/null +++ b/protocol/proto/PlaybackError.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlaybackError { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional string track_id = 3; + optional int64 bitrate = 4; + optional int64 error_code = 5; + optional bool fatal = 6; + optional string audiocodec = 7; + optional bool external_track = 8; + optional int64 position_ms = 9; +} diff --git a/protocol/proto/PlaybackRetry.proto b/protocol/proto/PlaybackRetry.proto new file mode 100644 index 00000000..82b9e9b3 --- /dev/null +++ b/protocol/proto/PlaybackRetry.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlaybackRetry { + optional string track = 1; + optional bytes playback_id = 2; + optional string method = 3; + optional string status = 4; + optional string reason = 5; +} diff --git a/protocol/proto/PlaybackSegments.proto b/protocol/proto/PlaybackSegments.proto new file mode 100644 index 00000000..bd5026c7 --- /dev/null +++ b/protocol/proto/PlaybackSegments.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlaybackSegments { + optional bytes playback_id = 1; + optional string track_uri = 2; + optional bool overflow = 3; + optional string segments = 4; +} diff --git a/protocol/proto/PlayerStateRestore.proto b/protocol/proto/PlayerStateRestore.proto new file mode 100644 index 00000000..f9778a7a --- /dev/null +++ b/protocol/proto/PlayerStateRestore.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlayerStateRestore { + optional string error = 1; + optional int64 size = 2; + optional string context_uri = 3; + optional string state = 4; +} diff --git a/protocol/proto/PlaylistSyncEvent.proto b/protocol/proto/PlaylistSyncEvent.proto new file mode 100644 index 00000000..6f2a23e2 --- /dev/null +++ b/protocol/proto/PlaylistSyncEvent.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PlaylistSyncEvent { + optional string playlist_id = 1; + optional bool is_playlist = 2; + optional int64 timestamp_ms = 3; + optional int32 error_code = 4; + optional string event_description = 5; +} diff --git a/protocol/proto/PodcastAdSegmentReceived.proto b/protocol/proto/PodcastAdSegmentReceived.proto new file mode 100644 index 00000000..036fb6d5 --- /dev/null +++ b/protocol/proto/PodcastAdSegmentReceived.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PodcastAdSegmentReceived { + optional string episode_uri = 1; + optional string playback_id = 2; + optional string slots = 3; + optional bool is_audio = 4; +} diff --git a/protocol/proto/Prefetch.proto b/protocol/proto/Prefetch.proto new file mode 100644 index 00000000..c388668a --- /dev/null +++ b/protocol/proto/Prefetch.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Prefetch { + optional int64 strategies = 1; + optional int64 strategy = 2; + optional bytes file_id = 3; + optional string track = 4; + optional int64 prefetch_index = 5; + optional int64 current_window_size = 6; + optional int64 max_window_size = 7; +} diff --git a/protocol/proto/PrefetchError.proto b/protocol/proto/PrefetchError.proto new file mode 100644 index 00000000..6a1e56b4 --- /dev/null +++ b/protocol/proto/PrefetchError.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PrefetchError { + optional int64 strategy = 1; + optional string description = 2; +} diff --git a/protocol/proto/ProductStateUcsVerification.proto b/protocol/proto/ProductStateUcsVerification.proto new file mode 100644 index 00000000..95257538 --- /dev/null +++ b/protocol/proto/ProductStateUcsVerification.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ProductStateUcsVerification { + map additional_entries = 1; + map missing_entries = 2; + optional string fetch_type = 3; +} diff --git a/protocol/proto/PubSubCountPerIdent.proto b/protocol/proto/PubSubCountPerIdent.proto new file mode 100644 index 00000000..a2d1e097 --- /dev/null +++ b/protocol/proto/PubSubCountPerIdent.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message PubSubCountPerIdent { + optional string ident_filter = 1; + optional int32 no_of_messages_received = 2; + optional int32 no_of_failed_conversions = 3; +} diff --git a/protocol/proto/RawCoreStream.proto b/protocol/proto/RawCoreStream.proto new file mode 100644 index 00000000..848b945b --- /dev/null +++ b/protocol/proto/RawCoreStream.proto @@ -0,0 +1,52 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RawCoreStream { + optional bytes playback_id = 1; + optional bytes parent_playback_id = 2; + optional string video_session_id = 3; + optional bytes media_id = 4; + optional string media_type = 5; + optional string feature_identifier = 6; + optional string feature_version = 7; + optional string view_uri = 8; + optional string source_start = 9; + optional string reason_start = 10; + optional string source_end = 11; + optional string reason_end = 12; + optional int64 playback_start_time = 13; + optional int32 ms_played = 14; + optional int32 ms_played_nominal = 15; + optional int32 ms_played_overlapping = 16; + optional int32 ms_played_video = 17; + optional int32 ms_played_background = 18; + optional int32 ms_played_fullscreen = 19; + optional bool live = 20; + optional bool shuffle = 21; + optional string audio_format = 22; + optional string play_context = 23; + optional string content_uri = 24; + optional string displayed_content_uri = 25; + optional bool content_is_downloaded = 26; + optional bool incognito_mode = 27; + optional string provider = 28; + optional string referrer = 29; + optional string referrer_version = 30; + optional string referrer_vendor = 31; + optional string streaming_rule = 32; + optional string connect_controller_device_id = 33; + optional string page_instance_id = 34; + optional string interaction_id = 35; + optional string parent_content_uri = 36; + optional int64 core_version = 37; + optional string core_bundle = 38; + optional bool is_assumed_premium = 39; + optional int32 ms_played_external = 40; + optional string local_content_uri = 41; + optional bool client_offline_at_stream_start = 42; +} diff --git a/protocol/proto/ReachabilityChanged.proto b/protocol/proto/ReachabilityChanged.proto new file mode 100644 index 00000000..d8e3bc10 --- /dev/null +++ b/protocol/proto/ReachabilityChanged.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ReachabilityChanged { + optional string type = 1; + optional string info = 2; +} diff --git a/protocol/proto/RejectedClientEventNonAuth.proto b/protocol/proto/RejectedClientEventNonAuth.proto new file mode 100644 index 00000000..d592809b --- /dev/null +++ b/protocol/proto/RejectedClientEventNonAuth.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RejectedClientEventNonAuth { + optional string reject_reason = 1; + optional string event_name = 2; +} diff --git a/protocol/proto/RemainingSkips.proto b/protocol/proto/RemainingSkips.proto new file mode 100644 index 00000000..d6ceebc0 --- /dev/null +++ b/protocol/proto/RemainingSkips.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RemainingSkips { + optional string interaction_id = 1; + optional int32 remaining_skips_before_skip = 2; + optional int32 remaining_skips_after_skip = 3; + repeated string interaction_ids = 4; +} diff --git a/protocol/proto/RequestAccounting.proto b/protocol/proto/RequestAccounting.proto new file mode 100644 index 00000000..897cffb9 --- /dev/null +++ b/protocol/proto/RequestAccounting.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RequestAccounting { + optional string request = 1; + optional int64 downloaded = 2; + optional int64 uploaded = 3; + optional int64 num_requests = 4; + optional string connection = 5; + optional string source_identifier = 6; + optional string reason = 7; + optional int64 duration_ms = 8; +} diff --git a/protocol/proto/RequestTime.proto b/protocol/proto/RequestTime.proto new file mode 100644 index 00000000..f0b7134f --- /dev/null +++ b/protocol/proto/RequestTime.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RequestTime { + optional string type = 1; + optional int64 first_byte = 2; + optional int64 last_byte = 3; + optional int64 size = 4; + optional int64 size_sent = 5; + optional bool error = 6; + optional string url = 7; + optional string verb = 8; + optional int64 payload_size_sent = 9; + optional int32 connection_reuse = 10; + optional double sampling_probability = 11; + optional bool cached = 12; +} diff --git a/protocol/proto/StartTrack.proto b/protocol/proto/StartTrack.proto new file mode 100644 index 00000000..5bbf5273 --- /dev/null +++ b/protocol/proto/StartTrack.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message StartTrack { + optional bytes playback_id = 1; + optional string context_player_session_id = 2; + optional int64 timestamp = 3; +} diff --git a/protocol/proto/Stutter.proto b/protocol/proto/Stutter.proto new file mode 100644 index 00000000..bd0b2980 --- /dev/null +++ b/protocol/proto/Stutter.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Stutter { + optional bytes file_id = 1; + optional bytes playback_id = 2; + optional string track = 3; + optional int64 buffer_size = 4; + optional int64 max_buffer_size = 5; + optional int64 file_byte_offset = 6; + optional int64 file_byte_total = 7; + optional int64 target_buffer = 8; + optional string audio_driver = 9; +} diff --git a/protocol/proto/TierFeatureFlags.proto b/protocol/proto/TierFeatureFlags.proto new file mode 100644 index 00000000..01f4311f --- /dev/null +++ b/protocol/proto/TierFeatureFlags.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message TierFeatureFlags { + optional bool ads = 1; + optional bool high_quality = 2; + optional bool offline = 3; + optional bool on_demand = 4; + optional string max_album_plays_consecutive = 5; + optional string max_album_plays_per_hour = 6; + optional string max_skips_per_hour = 7; + optional string max_track_plays_per_hour = 8; +} diff --git a/protocol/proto/TrackNotPlayed.proto b/protocol/proto/TrackNotPlayed.proto new file mode 100644 index 00000000..58c3ead2 --- /dev/null +++ b/protocol/proto/TrackNotPlayed.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message TrackNotPlayed { + optional bytes playback_id = 1; + optional string source_start = 2; + optional string reason_start = 3; + optional string source_end = 4; + optional string reason_end = 5; + optional string play_context = 6; + optional string play_track = 7; + optional string display_track = 8; + optional string provider = 9; + optional string referer = 10; + optional string referrer_version = 11; + optional string referrer_vendor = 12; + optional string gaia_dev_id = 13; + optional string reason_not_played = 14; +} diff --git a/protocol/proto/TrackStuck.proto b/protocol/proto/TrackStuck.proto new file mode 100644 index 00000000..566d6494 --- /dev/null +++ b/protocol/proto/TrackStuck.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message TrackStuck { + optional string track = 1; + optional bytes playback_id = 2; + optional string source_start = 3; + optional string reason_start = 4; + optional bool offline = 5; + optional int64 position = 6; + optional int64 count = 7; + optional string audio_driver = 8; +} diff --git a/protocol/proto/WindowSize.proto b/protocol/proto/WindowSize.proto new file mode 100644 index 00000000..7860b1e7 --- /dev/null +++ b/protocol/proto/WindowSize.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message WindowSize { + optional int64 width = 1; + optional int64 height = 2; + optional int64 mode = 3; + optional int64 duration = 4; +} diff --git a/protocol/proto/ad-hermes-proxy.proto b/protocol/proto/ad-hermes-proxy.proto deleted file mode 100644 index 219bbcbf..00000000 --- a/protocol/proto/ad-hermes-proxy.proto +++ /dev/null @@ -1,51 +0,0 @@ -syntax = "proto2"; - -message Rule { - optional string type = 0x1; - optional uint32 times = 0x2; - optional uint64 interval = 0x3; -} - -message AdRequest { - optional string client_language = 0x1; - optional string product = 0x2; - optional uint32 version = 0x3; - optional string type = 0x4; - repeated string avoidAds = 0x5; -} - -message AdQueueResponse { - repeated AdQueueEntry adQueueEntry = 0x1; -} - -message AdFile { - optional string id = 0x1; - optional string format = 0x2; -} - -message AdQueueEntry { - optional uint64 start_time = 0x1; - optional uint64 end_time = 0x2; - optional double priority = 0x3; - optional string token = 0x4; - optional uint32 ad_version = 0x5; - optional string id = 0x6; - optional string type = 0x7; - optional string campaign = 0x8; - optional string advertiser = 0x9; - optional string url = 0xa; - optional uint64 duration = 0xb; - optional uint64 expiry = 0xc; - optional string tracking_url = 0xd; - optional string banner_type = 0xe; - optional string html = 0xf; - optional string image = 0x10; - optional string background_image = 0x11; - optional string background_url = 0x12; - optional string background_color = 0x13; - optional string title = 0x14; - optional string caption = 0x15; - repeated AdFile file = 0x16; - repeated Rule rule = 0x17; -} - diff --git a/protocol/proto/apiv1.proto b/protocol/proto/apiv1.proto new file mode 100644 index 00000000..e8a10b4e --- /dev/null +++ b/protocol/proto/apiv1.proto @@ -0,0 +1,163 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.offline.proto; + +import "google/protobuf/timestamp.proto"; +import "offline.proto"; + +option optimize_for = CODE_SIZE; + +message ListDevicesRequest { + string user_id = 1; +} + +message ListDevicesResponse { + repeated Device devices = 1; +} + +message PutDeviceRequest { + string user_id = 1; + + Body body = 2; + message Body { + Device device = 1; + } +} + +message BasicDeviceRequest { + DeviceKey key = 1; +} + +message GetDeviceResponse { + Device device = 1; +} + +message RemoveDeviceRequest { + DeviceKey key = 1; + bool is_force_remove = 2; +} + +message OfflineEnableDeviceRequest { + message Body { + bool auto_opc = 1; + } + + DeviceKey key = 1; + Body body = 2; + string name = 9; + int32 platform = 7; + string client_id = 8; +} + +message OfflineEnableDeviceResponse { + enum StatusCode { + UNKNOWN = 0; + OK = 1; + DEVICE_LIMIT_REACHED = 2; + } + + Restrictions restrictions = 1; + StatusCode status_code = 2; +} + +message ListResourcesResponse { + repeated Resource resources = 1; + google.protobuf.Timestamp server_time = 2; +} + +message WriteResourcesRequest { + DeviceKey key = 1; + + Body body = 2; + message Body { + repeated ResourceOperation operations = 1; + string source_device_id = 2; + string source_cache_id = 3; + } +} + +message ResourcesUpdate { + string source_device_id = 1; + string source_cache_id = 2; +} + +message DeltaResourcesRequest { + DeviceKey key = 1; + + Body body = 2; + message Body { + google.protobuf.Timestamp last_known_server_time = 1; + } +} + +message DeltaResourcesResponse { + bool delta_update_possible = 1; + repeated ResourceOperation operations = 2; + google.protobuf.Timestamp server_time = 3; +} + +message GetResourceRequest { + DeviceKey key = 1; + string uri = 2; +} + +message GetResourceResponse { + Resource resource = 1; +} + +message WriteResourcesDetailsRequest { + DeviceKey key = 1; + + Body body = 2; + message Body { + repeated Resource resources = 1; + } +} + +message GetResourceForDevicesRequest { + string user_id = 1; + string uri = 2; +} + +message GetResourceForDevicesResponse { + repeated Device devices = 1; + repeated ResourceForDevice resources = 2; +} + +message ListDevicesWithResourceRequest { + message Body { + string uri = 1; + } + + string user_id = 1; + string username = 2; + Body body = 3; +} + +message ListDevicesWithResourceResponse { + message DeviceWithResource { + Device device = 1; + bool is_supported = 2; + optional Resource resource = 3; + } + + repeated DeviceWithResource deviceWithResource = 1; + FetchStrategy fetch_strategy = 2; +} + +message FetchStrategy { + oneof fetch_strategy { + PollStrategy poll_strategy = 1; + SubStrategy sub_strategy = 2; + } +} + +message PollStrategy { + int32 interval_ms = 1; +} + +message SubStrategy { +} + diff --git a/protocol/proto/app_state.proto b/protocol/proto/app_state.proto new file mode 100644 index 00000000..fb4b07a4 --- /dev/null +++ b/protocol/proto/app_state.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.offline.proto; + +option optimize_for = CODE_SIZE; + +message AppStateRequest { + AppState state = 1; +} + +enum AppState { + UNKNOWN = 0; + BACKGROUND = 1; + FOREGROUND = 2; +} diff --git a/protocol/proto/appstore.proto b/protocol/proto/appstore.proto deleted file mode 100644 index bddaaf30..00000000 --- a/protocol/proto/appstore.proto +++ /dev/null @@ -1,95 +0,0 @@ -syntax = "proto2"; - -message AppInfo { - optional string identifier = 0x1; - optional int32 version_int = 0x2; -} - -message AppInfoList { - repeated AppInfo items = 0x1; -} - -message SemanticVersion { - optional int32 major = 0x1; - optional int32 minor = 0x2; - optional int32 patch = 0x3; -} - -message RequestHeader { - optional string market = 0x1; - optional Platform platform = 0x2; - enum Platform { - WIN32_X86 = 0x0; - OSX_X86 = 0x1; - LINUX_X86 = 0x2; - IPHONE_ARM = 0x3; - SYMBIANS60_ARM = 0x4; - OSX_POWERPC = 0x5; - ANDROID_ARM = 0x6; - WINCE_ARM = 0x7; - LINUX_X86_64 = 0x8; - OSX_X86_64 = 0x9; - PALM_ARM = 0xa; - LINUX_SH = 0xb; - FREEBSD_X86 = 0xc; - FREEBSD_X86_64 = 0xd; - BLACKBERRY_ARM = 0xe; - SONOS_UNKNOWN = 0xf; - LINUX_MIPS = 0x10; - LINUX_ARM = 0x11; - LOGITECH_ARM = 0x12; - LINUX_BLACKFIN = 0x13; - ONKYO_ARM = 0x15; - QNXNTO_ARM = 0x16; - BADPLATFORM = 0xff; - } - optional AppInfoList app_infos = 0x6; - optional string bridge_identifier = 0x7; - optional SemanticVersion bridge_version = 0x8; - optional DeviceClass device_class = 0x9; - enum DeviceClass { - DESKTOP = 0x1; - TABLET = 0x2; - MOBILE = 0x3; - WEB = 0x4; - TV = 0x5; - } -} - -message AppItem { - optional string identifier = 0x1; - optional Requirement requirement = 0x2; - enum Requirement { - REQUIRED_INSTALL = 0x1; - LAZYLOAD = 0x2; - OPTIONAL_INSTALL = 0x3; - } - optional string manifest = 0x4; - optional string checksum = 0x5; - optional string bundle_uri = 0x6; - optional string small_icon_uri = 0x7; - optional string large_icon_uri = 0x8; - optional string medium_icon_uri = 0x9; - optional Type bundle_type = 0xa; - enum Type { - APPLICATION = 0x0; - FRAMEWORK = 0x1; - BRIDGE = 0x2; - } - optional SemanticVersion version = 0xb; - optional uint32 ttl_in_seconds = 0xc; - optional IdentifierList categories = 0xd; -} - -message AppList { - repeated AppItem items = 0x1; -} - -message IdentifierList { - repeated string identifiers = 0x1; -} - -message BannerConfig { - optional string json = 0x1; -} - diff --git a/protocol/proto/audio_files_extension.proto b/protocol/proto/audio_files_extension.proto new file mode 100644 index 00000000..7c0a8b62 --- /dev/null +++ b/protocol/proto/audio_files_extension.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.extendedmetadata.audiofiles; + +import "metadata.proto"; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.audiophile.proto"; + +message NormalizationParams { + float loudness_db = 1; + float true_peak_db = 2; +} + +message ExtendedAudioFile { + reserved 2; + reserved 3; + metadata.AudioFile file = 1; + int32 average_bitrate = 4; +} + +message AudioFilesExtensionResponse { + repeated ExtendedAudioFile files = 1; + NormalizationParams default_file_normalization_params = 2; + NormalizationParams default_album_normalization_params = 3; + bytes audio_id = 4; +} diff --git a/protocol/proto/audio_format.proto b/protocol/proto/audio_format.proto new file mode 100644 index 00000000..1985c37b --- /dev/null +++ b/protocol/proto/audio_format.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option java_package = "com.spotify.stream_reporting_esperanto.proto"; +option objc_class_prefix = "ESP"; + +enum AudioFormat { + FORMAT_UNKNOWN = 0; + FORMAT_OGG_VORBIS_96 = 1; + FORMAT_OGG_VORBIS_160 = 2; + FORMAT_OGG_VORBIS_320 = 3; + FORMAT_MP3_256 = 4; + FORMAT_MP3_320 = 5; + FORMAT_MP3_160 = 6; + FORMAT_MP3_96 = 7; + FORMAT_MP3_160_ENCRYPTED = 8; + FORMAT_AAC_24 = 9; + FORMAT_AAC_48 = 10; + FORMAT_MP4_128 = 11; + FORMAT_MP4_128_DUAL = 12; + FORMAT_MP4_128_CBCS = 13; + FORMAT_MP4_256 = 14; + FORMAT_MP4_256_DUAL = 15; + FORMAT_MP4_256_CBCS = 16; + FORMAT_FLAC_FLAC = 17; + FORMAT_MP4_FLAC = 18; + FORMAT_MP4_Unknown = 19; + FORMAT_MP3_Unknown = 20; + FORMAT_XHE_AAC_12 = 21; + FORMAT_XHE_AAC_16 = 22; + FORMAT_XHE_AAC_24 = 23; + FORMAT_FLAC_FLAC_24 = 24; +} + diff --git a/protocol/proto/autodownload_backend_service.proto b/protocol/proto/autodownload_backend_service.proto new file mode 100644 index 00000000..69ee6dfe --- /dev/null +++ b/protocol/proto/autodownload_backend_service.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.autodownloadservice.v1.proto; + +import "google/protobuf/timestamp.proto"; + +message Identifiers { + string device_id = 1; + string cache_id = 2; +} + +message Settings { + oneof episode_download { + bool most_recent_no_limit = 1; + int32 most_recent_count = 2; + } +} + +message SetSettingsRequest { + Identifiers identifiers = 1; + Settings settings = 2; + google.protobuf.Timestamp client_timestamp = 3; +} + +message GetSettingsRequest { + Identifiers identifiers = 1; +} + +message GetSettingsResponse { + Settings settings = 1; +} + +message ShowRequest { + Identifiers identifiers = 1; + string show_uri = 2; + google.protobuf.Timestamp client_timestamp = 3; +} + +message ReplaceIdentifiersRequest { + Identifiers old_identifiers = 1; + Identifiers new_identifiers = 2; +} + +message PendingItem { + google.protobuf.Timestamp client_timestamp = 1; + + oneof pending { + bool is_removed = 2; + Settings settings = 3; + } +} diff --git a/protocol/proto/autodownload_config_common.proto b/protocol/proto/autodownload_config_common.proto new file mode 100644 index 00000000..9d923f04 --- /dev/null +++ b/protocol/proto/autodownload_config_common.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.autodownload_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.autodownload.esperanto.proto"; + +message AutoDownloadGlobalConfig { + uint32 number_of_episodes = 1; +} + +message AutoDownloadShowConfig { + string uri = 1; + bool active = 2; +} diff --git a/protocol/proto/autodownload_config_get_request.proto b/protocol/proto/autodownload_config_get_request.proto new file mode 100644 index 00000000..be4681bb --- /dev/null +++ b/protocol/proto/autodownload_config_get_request.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.autodownload_esperanto.proto; + +import "autodownload_config_common.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.autodownload.esperanto.proto"; + +message AutoDownloadGetRequest { + repeated string uri = 1; +} + +message AutoDownloadGetResponse { + AutoDownloadGlobalConfig global = 1; + repeated AutoDownloadShowConfig show = 2; + string error = 99; +} diff --git a/protocol/proto/autodownload_config_set_request.proto b/protocol/proto/autodownload_config_set_request.proto new file mode 100644 index 00000000..2adcbeab --- /dev/null +++ b/protocol/proto/autodownload_config_set_request.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.autodownload_esperanto.proto; + +import "autodownload_config_common.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.autodownload.esperanto.proto"; + +message AutoDownloadSetRequest { + oneof config { + AutoDownloadGlobalConfig global = 1; + AutoDownloadShowConfig show = 2; + } +} + +message AutoDownloadSetResponse { + string error = 99; +} diff --git a/protocol/proto/automix_mode.proto b/protocol/proto/automix_mode.proto new file mode 100644 index 00000000..50f730ca --- /dev/null +++ b/protocol/proto/automix_mode.proto @@ -0,0 +1,45 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.automix.proto; + +option optimize_for = CODE_SIZE; + +message AutomixConfig { + TransitionType transition_type = 1; + string fade_out_curves = 2; + string fade_in_curves = 3; + int32 beats_min = 4; + int32 beats_max = 5; + int32 fade_duration_max_ms = 6; +} + +message AutomixMode { + AutomixStyle style = 1; + AutomixConfig config = 2; + AutomixConfig ml_config = 3; + AutomixConfig shuffle_config = 4; + AutomixConfig shuffle_ml_config = 5; +} + +enum AutomixStyle { + NONE = 0; + DEFAULT = 1; + REGULAR = 2; + AIRBAG = 3; + RADIO_AIRBAG = 4; + SLEEP = 5; + MIXED = 6; + CUSTOM = 7; + HEURISTIC = 8; + BACKEND = 9; +} + +enum TransitionType { + CUEPOINTS = 0; + CROSSFADE = 1; + GAPLESS = 2; + HEURISTIC_TRANSITION = 3; + BACKEND_TRANSITION = 4; +} diff --git a/protocol/proto/autoplay_context_request.proto b/protocol/proto/autoplay_context_request.proto new file mode 100644 index 00000000..35b12b07 --- /dev/null +++ b/protocol/proto/autoplay_context_request.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message AutoplayContextRequest { + required string context_uri = 1; + repeated string recent_track_uri = 2; + optional bool is_video = 3; +} diff --git a/protocol/proto/autoplay_node.proto b/protocol/proto/autoplay_node.proto new file mode 100644 index 00000000..9d8012a6 --- /dev/null +++ b/protocol/proto/autoplay_node.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "logging_params.proto"; + +option optimize_for = CODE_SIZE; + +message AutoplayNode { + map filler_node = 1; + required bool is_playing_filler = 2; + required LoggingParams logging_params = 3; + optional bool called_play_on_filler = 4; +} diff --git a/protocol/proto/canvas.proto b/protocol/proto/canvas.proto new file mode 100644 index 00000000..e008618e --- /dev/null +++ b/protocol/proto/canvas.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.context_track_exts.canvas; + +message Artist { + string uri = 1; + string name = 2; + string avatar = 3; +} + +message CanvasRecord { + string id = 1; + string url = 2; + string file_id = 3; + Type type = 4; + string entity_uri = 5; + Artist artist = 6; + bool explicit = 7; + string uploaded_by = 8; + string etag = 9; + string canvas_uri = 11; + string storylines_id = 12; +} + +enum Type { + IMAGE = 0; + VIDEO = 1; + VIDEO_LOOPING = 2; + VIDEO_LOOPING_RANDOM = 3; + GIF = 4; +} diff --git a/protocol/proto/canvas_storage.proto b/protocol/proto/canvas_storage.proto new file mode 100644 index 00000000..e2f652c2 --- /dev/null +++ b/protocol/proto/canvas_storage.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.canvas.proto.storage; + +import "canvaz.proto"; + +option optimize_for = CODE_SIZE; + +message CanvasCacheEntry { + string entity_uri = 1; + uint64 expires_on_seconds = 2; + canvaz.cache.EntityCanvazResponse.Canvaz canvas = 3; +} + +message CanvasCacheFile { + repeated CanvasCacheEntry entries = 1; +} diff --git a/protocol/proto/canvaz-meta.proto b/protocol/proto/canvaz-meta.proto new file mode 100644 index 00000000..b3b55531 --- /dev/null +++ b/protocol/proto/canvaz-meta.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.canvaz; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.canvaz.proto"; + +enum Type { + IMAGE = 0; + VIDEO = 1; + VIDEO_LOOPING = 2; + VIDEO_LOOPING_RANDOM = 3; + GIF = 4; +} diff --git a/protocol/proto/canvaz.proto b/protocol/proto/canvaz.proto new file mode 100644 index 00000000..936a5332 --- /dev/null +++ b/protocol/proto/canvaz.proto @@ -0,0 +1,41 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.canvaz.cache; + +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 { + message Canvaz { + string id = 1; + string url = 2; + string file_id = 3; + Type type = 4; + string entity_uri = 5; + Artist artist = 6; + bool explicit = 7; + string uploaded_by = 8; + string etag = 9; + string canvas_uri = 11; + string storylines_id = 12; + } + +} + +enum Type { + IMAGE = 0; + VIDEO = 1; + VIDEO_LOOPING = 2; + VIDEO_LOOPING_RANDOM = 3; + GIF = 4; +} + diff --git a/protocol/proto/capping_data.proto b/protocol/proto/capping_data.proto new file mode 100644 index 00000000..dca6353a --- /dev/null +++ b/protocol/proto/capping_data.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.capper3; + +option java_multiple_files = true; +option java_package = "com.spotify.capper3.proto"; + +message ConsumeTokensRequest { + uint32 tokens = 1; +} + +message CappingData { + uint32 remaining_tokens = 1; + uint32 capacity = 2; + uint32 seconds_until_next_refill = 3; + uint32 refill_amount = 4; +} + +message ConsumeTokensResponse { + uint32 seconds_until_next_update = 1; + PlayCappingType capping_type = 2; + CappingData capping_data = 3; +} + +enum PlayCappingType { + NONE = 0; + LINEAR = 1; +} diff --git a/protocol/proto/claas.proto b/protocol/proto/claas.proto new file mode 100644 index 00000000..6006c17b --- /dev/null +++ b/protocol/proto/claas.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.claas.v1; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.claas.v1"; + +service ClaasService { + rpc PostLogs(PostLogsRequest) returns (PostLogsResponse); + rpc Watch(WatchRequest) returns (stream WatchResponse); +} + +message WatchRequest { + string user_id = 1; +} + +message WatchResponse { + repeated string logs = 1; +} + +message PostLogsRequest { + repeated string logs = 1; +} + +message PostLogsResponse { + +} diff --git a/protocol/proto/client-tts.proto b/protocol/proto/client-tts.proto new file mode 100644 index 00000000..fe40b025 --- /dev/null +++ b/protocol/proto/client-tts.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.narration.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..17734462 --- /dev/null +++ b/protocol/proto/client_config.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.extendedmetadata.config.v1; + +option optimize_for = CODE_SIZE; + +message ClientConfig { + uint32 log_sampling_rate = 1; + uint32 avg_log_messages_per_minute = 2; + uint32 log_messages_burst_size = 3; +} diff --git a/protocol/proto/client_update.proto b/protocol/proto/client_update.proto new file mode 100644 index 00000000..c7aa5ecb --- /dev/null +++ b/protocol/proto/client_update.proto @@ -0,0 +1,39 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.desktopupdate.proto; + +option java_multiple_files = true; +option java_outer_classname = "ClientUpdateProto"; +option java_package = "com.spotify.desktopupdate.proto"; + +message UpgradeSignedPart { + uint32 platform = 1; + uint64 version_from_from = 2; + uint64 version_from_to = 3; + uint64 target_version = 4; + string http_prefix = 5; + bytes binary_hash = 6; + ClientUpgradeType type = 7; + bytes file_id = 8; + uint32 delay = 9; + uint32 flags = 10; +} + +message UpgradeRequiredMessage { + bytes upgrade_signed_part = 10; + bytes signature = 20; + string http_suffix = 30; +} + +message UpdateQueryResponse { + UpgradeRequiredMessage upgrade_message_payload = 1; + uint32 poll_interval = 2; +} + +enum ClientUpgradeType { + INVALID = 0; + LOGIN_CRITICAL = 1; + NORMAL = 2; +} diff --git a/protocol/proto/clips_cover.proto b/protocol/proto/clips_cover.proto new file mode 100644 index 00000000..b129fb4a --- /dev/null +++ b/protocol/proto/clips_cover.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.clips; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "ClipsCoverProto"; +option java_package = "com.spotify.clips.proto"; + +message ClipsCover { + string image_url = 1; + string video_source_id = 2; +} diff --git a/protocol/proto/collection/album_collection_state.proto b/protocol/proto/collection/album_collection_state.proto new file mode 100644 index 00000000..c4cfbfed --- /dev/null +++ b/protocol/proto/collection/album_collection_state.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message AlbumCollectionState { + optional string collection_link = 1; + optional uint32 num_tracks_in_collection = 2; + optional bool complete = 3; +} diff --git a/protocol/proto/collection/artist_collection_state.proto b/protocol/proto/collection/artist_collection_state.proto new file mode 100644 index 00000000..cdc001a7 --- /dev/null +++ b/protocol/proto/collection/artist_collection_state.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ArtistCollectionState { + optional string collection_link = 1; + optional bool followed = 2; + optional uint32 num_tracks_in_collection = 3; + optional uint32 num_albums_in_collection = 4; + optional bool is_banned = 5; + optional bool can_ban = 6; + optional uint32 num_explicitly_liked_tracks = 7; +} diff --git a/protocol/proto/collection/episode_collection_state.proto b/protocol/proto/collection/episode_collection_state.proto new file mode 100644 index 00000000..c900c17d --- /dev/null +++ b/protocol/proto/collection/episode_collection_state.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option objc_class_prefix = "SPTCosmosUtil"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message EpisodeCollectionState { + optional bool is_following_show = 1; + optional bool is_new = 2; + optional bool is_in_listen_later = 3; +} diff --git a/protocol/proto/collection/show_collection_state.proto b/protocol/proto/collection/show_collection_state.proto new file mode 100644 index 00000000..6b9e4101 --- /dev/null +++ b/protocol/proto/collection/show_collection_state.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ShowCollectionState { + optional bool is_in_collection = 1; +} diff --git a/protocol/proto/collection/track_collection_state.proto b/protocol/proto/collection/track_collection_state.proto new file mode 100644 index 00000000..6782cb17 --- /dev/null +++ b/protocol/proto/collection/track_collection_state.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message TrackCollectionState { + optional bool is_in_collection = 1; + optional bool can_add_to_collection = 2; + optional bool is_banned = 3; + optional bool can_ban = 4; +} diff --git a/protocol/proto/collection2v2.proto b/protocol/proto/collection2v2.proto new file mode 100644 index 00000000..71d4b75c --- /dev/null +++ b/protocol/proto/collection2v2.proto @@ -0,0 +1,56 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection.proto.v2; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.collection2.v2.proto"; + +message PageRequest { + string username = 1; + string set = 2; + string pagination_token = 3; + int32 limit = 4; +} + +message CollectionItem { + string uri = 1; + int32 added_at = 2; + bool is_removed = 3; + optional string context_uri = 4; +} + +message PageResponse { + repeated CollectionItem items = 1; + string next_page_token = 2; + string sync_token = 3; +} + +message DeltaRequest { + string username = 1; + string set = 2; + string last_sync_token = 3; +} + +message DeltaResponse { + bool delta_update_possible = 1; + repeated CollectionItem items = 2; + string sync_token = 3; +} + +message WriteRequest { + string username = 1; + string set = 2; + repeated CollectionItem items = 3; + string client_update_id = 4; +} + +message InitializedRequest { + string username = 1; + string set = 2; +} + +message InitializedResponse { + bool initialized = 1; +} diff --git a/protocol/proto/collection_add_remove_items_request.proto b/protocol/proto/collection_add_remove_items_request.proto new file mode 100644 index 00000000..6be1eb2d --- /dev/null +++ b/protocol/proto/collection_add_remove_items_request.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "status.proto"; + +option java_package = "spotify.collection.esperanto.proto"; +option java_multiple_files = true; +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; + +message CollectionAddRemoveItemsRequest { + repeated string uri = 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..78cb0c55 --- /dev/null +++ b/protocol/proto/collection_ban_request.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "status.proto"; + +option java_package = "spotify.collection.esperanto.proto"; +option java_multiple_files = true; +option objc_class_prefix = "ESP"; +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..e673b86f --- /dev/null +++ b/protocol/proto/collection_decoration_policy.proto @@ -0,0 +1,61 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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"; +import "policy/show_decoration_policy.proto"; +import "policy/episode_decoration_policy.proto"; + +option java_package = "spotify.collection.esperanto.proto"; +option java_multiple_files = true; +option objc_class_prefix = "ESP"; +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; + cosmos_util.proto.ArtistCollectionDecorationPolicy artist_collection_policy = 8; +} + +message CollectionShowDecorationPolicy { + cosmos_util.proto.ShowDecorationPolicy show_policy = 1; + cosmos_util.proto.ShowPlayedStateDecorationPolicy played_state_policy = 2; + cosmos_util.proto.ShowCollectionDecorationPolicy collection_policy = 3; + bool decorated = 4; +} + +message CollectionEpisodeDecorationPolicy { + cosmos_util.proto.EpisodeDecorationPolicy episode_policy = 1; + cosmos_util.proto.EpisodeCollectionDecorationPolicy collection_policy = 2; + cosmos_util.proto.EpisodeSyncDecorationPolicy sync_policy = 3; + cosmos_util.proto.EpisodePlayedStateDecorationPolicy played_state_policy = 4; + CollectionShowDecorationPolicy show_policy = 5; + bool decorated = 6; +} + diff --git a/protocol/proto/collection_get_bans_request.proto b/protocol/proto/collection_get_bans_request.proto new file mode 100644 index 00000000..bb4c44f2 --- /dev/null +++ b/protocol/proto/collection_get_bans_request.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "collection_decoration_policy.proto"; +import "collection_item.proto"; +import "status.proto"; + +option java_multiple_files = true; +option java_package = "spotify.collection.esperanto.proto"; +option objc_class_prefix = "SPTCollectionCosmos"; +option optimize_for = CODE_SIZE; + +message CollectionGetBansRequest { + CollectionTrackDecorationPolicy track_policy = 1; + CollectionArtistDecorationPolicy artist_policy = 2; + string sort = 3; + bool timestamp = 4; + uint32 update_throttling = 5; +} + +message Item { + uint32 add_time = 1; + CollectionTrack track_metadata = 2; + CollectionArtist artist_metadata = 3; +} + +message CollectionGetBansResponse { + Status status = 1; + repeated Item item = 2; +} diff --git a/protocol/proto/collection_index.proto b/protocol/proto/collection_index.proto new file mode 100644 index 00000000..359e8eb3 --- /dev/null +++ b/protocol/proto/collection_index.proto @@ -0,0 +1,78 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection.proto; + +option optimize_for = CODE_SIZE; + +message IndexRepairerState { + bytes last_checked_uri = 1; + int64 last_full_check_finished_at = 2; +} + +message AddTime { + int64 timestamp = 1; +} + +message CollectionTrackEntry { + string uri = 1; + string track_name = 2; + string album_uri = 3; + string album_name = 4; + int32 disc_number = 5; + int32 track_number = 6; + string artist_uri = 7; + repeated string artist_name = 8; + int64 add_time = 9; +} + +message CollectionAlbumEntry { + string uri = 1; + string album_name = 2; + string artist_uri = 4; + string artist_name = 5; + int64 add_time = 6; + int64 last_played = 8; + int64 release_date = 9; +} + +message CollectionShowEntry { + string uri = 1; + string show_name = 2; + string creator_name = 5; + int64 add_time = 6; + int64 publish_date = 7; + int64 last_played = 8; +} + +message CollectionBookEntry { + string uri = 1; + string book_name = 2; + string author_name = 5; + int64 add_time = 6; + int64 last_played = 8; +} + +message CollectionArtistEntry { + string uri = 1; + string artist_name = 2; + int64 add_time = 4; + int64 last_played = 8; +} + +message CollectionAuthorEntry { + string uri = 1; + string author_name = 2; + int64 add_time = 4; +} + +message CollectionEpisodeEntry { + string uri = 1; + string episode_name = 2; + string show_uri = 3; + string show_name = 4; + int64 add_time = 5; + int64 publish_time = 6; +} + diff --git a/protocol/proto/collection_item.proto b/protocol/proto/collection_item.proto new file mode 100644 index 00000000..514de22f --- /dev/null +++ b/protocol/proto/collection_item.proto @@ -0,0 +1,80 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +import "metadata/album_metadata.proto"; +import "metadata/artist_metadata.proto"; +import "metadata/track_metadata.proto"; +import "metadata/show_metadata.proto"; +import "metadata/episode_metadata.proto"; +import "collection/artist_collection_state.proto"; +import "collection/album_collection_state.proto"; +import "collection/track_collection_state.proto"; +import "collection/show_collection_state.proto"; +import "collection/episode_collection_state.proto"; +import "sync/artist_sync_state.proto"; +import "sync/album_sync_state.proto"; +import "sync/track_sync_state.proto"; +import "sync/episode_sync_state.proto"; +import "played_state/track_played_state.proto"; +import "played_state/show_played_state.proto"; +import "played_state/episode_played_state.proto"; + +option java_package = "spotify.collection.esperanto.proto"; +option java_multiple_files = true; +option objc_class_prefix = "ESP"; +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; + string link = 10; + repeated cosmos_util.proto.ArtistCollectionState artist_collection_state = 11; +} + +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; + string link = 11; +} + +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; + string link = 6; +} + +message CollectionShow { + cosmos_util.proto.ShowMetadata show_metadata = 1; + cosmos_util.proto.ShowCollectionState show_collection_state = 2; + cosmos_util.proto.ShowPlayState show_play_state = 3; + uint32 add_time = 4; + string link = 5; +} + +message CollectionEpisode { + cosmos_util.proto.EpisodeMetadata episode_metadata = 1; + cosmos_util.proto.EpisodeCollectionState episode_collection_state = 2; + cosmos_util.proto.EpisodeSyncState episode_offline_state = 3; + cosmos_util.proto.EpisodePlayState episode_play_state = 4; + CollectionShow show = 5; + string link = 6; +} + diff --git a/protocol/proto/collection_platform_items.proto b/protocol/proto/collection_platform_items.proto new file mode 100644 index 00000000..fde06a05 --- /dev/null +++ b/protocol/proto/collection_platform_items.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package spotify.collection_platform.proto; + +option java_package = "com.spotify.collection_platform.esperanto.proto"; +option java_multiple_files = true; +option objc_class_prefix = "ESP"; + +message CollectionPlatformItem { + string uri = 1; + int64 add_time = 2; +} + +message CollectionPlatformContextItem { + string uri = 1; + int64 add_time = 2; + string context_uri = 3; +} + diff --git a/protocol/proto/collection_platform_requests.proto b/protocol/proto/collection_platform_requests.proto new file mode 100644 index 00000000..01bbd24d --- /dev/null +++ b/protocol/proto/collection_platform_requests.proto @@ -0,0 +1,36 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection_platform.proto; + +import "collection_platform_items.proto"; + +option java_package = "com.spotify.collection_platform.esperanto.proto"; +option java_multiple_files = true; +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; + +message CollectionPlatformItemsRequest { + CollectionSet set = 1; + repeated string items = 2; +} + +message CollectionPlatformContextItemsRequest { + CollectionSet set = 1; + repeated CollectionPlatformContextItem items = 2; +} + +enum CollectionSet { + UNKNOWN = 0; + IGNOREINRECS = 4; + ENHANCED = 5; + BANNED_ARTISTS = 8; + CONCERTS = 10; + TAGS = 11; + PRERELEASE = 12; + MARKED_AS_FINISHED = 13; + NOT_INTERESTED = 14; + LOCAL_BANS = 15; +} + diff --git a/protocol/proto/collection_platform_responses.proto b/protocol/proto/collection_platform_responses.proto new file mode 100644 index 00000000..98e2a091 --- /dev/null +++ b/protocol/proto/collection_platform_responses.proto @@ -0,0 +1,50 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection_platform.proto; + +import "collection_platform_items.proto"; + +option java_package = "com.spotify.collection_platform.esperanto.proto"; +option java_multiple_files = true; +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; + +message CollectionPlatformSimpleResponse { + string error_msg = 1; +} + +message CollectionPlatformItemsResponse { + repeated CollectionPlatformItem items = 1; +} + +message CollectionPlatformContainsResponse { + repeated bool found = 1; +} + +message Status { + int32 code = 1; + string reason = 2; +} + +message CollectionPlatformEsperantoContainsResponse { + Status status = 1; + CollectionPlatformContainsResponse contains = 2; +} + +message CollectionPlatformEsperantoItemsResponse { + Status status = 1; + repeated CollectionPlatformItem items = 2; +} + +message CollectionPlatformContextItemsResponse { + Status status = 1; + repeated CollectionPlatformContextItem items = 2; +} + +message CollectionPlatformContainsContextItemsResponse { + Status status = 1; + repeated bool found = 2; +} + diff --git a/protocol/proto/concat_cosmos.proto b/protocol/proto/concat_cosmos.proto new file mode 100644 index 00000000..7fe045a8 --- /dev/null +++ b/protocol/proto/concat_cosmos.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.concat_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message ConcatRequest { + string a = 1; + string b = 2; +} + +message ConcatWithSeparatorRequest { + string a = 1; + string b = 2; + string separator = 3; +} + +message ConcatResponse { + string concatenated = 1; +} diff --git a/protocol/proto/connect.proto b/protocol/proto/connect.proto new file mode 100644 index 00000000..9e10c796 --- /dev/null +++ b/protocol/proto/connect.proto @@ -0,0 +1,214 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.connectstate; + +import "player.proto"; +import "devices.proto"; +import "media.proto"; + +option java_package = "com.spotify.connectstate.model"; +option optimize_for = CODE_SIZE; + +message ClusterUpdate { + Cluster cluster = 1; + ClusterUpdateReason update_reason = 2; + string ack_id = 3; + repeated string devices_that_changed = 4; +} + +message Device { + DeviceInfo device_info = 1; + PlayerState player_state = 2; + PrivateDeviceInfo private_device_info = 3; + bytes transfer_data = 4; +} + +message Cluster { + reserved 7; + + int64 changed_timestamp_ms = 1; + string active_device_id = 2; + PlayerState player_state = 3; + map device = 4; + bytes transfer_data = 5; + uint64 transfer_data_timestamp = 6; + bool need_full_player_state = 8; + int64 server_timestamp_ms = 9; + optional bool needs_state_updates = 10; + optional uint64 started_playing_at_timestamp = 11; +} + +message PutStateRequest { + string callback_url = 1; + Device device = 2; + MemberType member_type = 3; + bool is_active = 4; + PutStateReason put_state_reason = 5; + uint32 message_id = 6; + string last_command_sent_by_device_id = 7; + uint32 last_command_message_id = 8; + uint64 started_playing_at = 9; + uint64 has_been_playing_for_ms = 11; + uint64 client_side_timestamp = 12; + bool only_write_player_state = 13; +} + +message PrivateDeviceInfo { + string platform = 1; +} + +message DeviceInfo { + reserved 5; + + bool can_play = 1; + uint32 volume = 2; + string name = 3; + Capabilities capabilities = 4; + string device_software_version = 6; + devices.DeviceType device_type = 7; + string spirc_version = 9; + string device_id = 10; + bool is_private_session = 11; + bool is_social_connect = 12; + string client_id = 13; + string brand = 14; + string model = 15; + map metadata_map = 16; + string product_id = 17; + string deduplication_id = 18; + uint32 selected_alias_id = 19; + map device_aliases = 20; + bool is_offline = 21; + string public_ip = 22; + string license = 23; + bool is_group = 25; + bool is_dynamic_device = 26; + repeated string disallow_playback_reasons = 27; + repeated string disallow_transfer_reasons = 28; + optional AudioOutputDeviceInfo audio_output_device_info = 24; +} + +message AudioOutputDeviceInfo { + optional AudioOutputDeviceType audio_output_device_type = 1; + optional string device_name = 2; +} + +message Capabilities { + reserved "supported_contexts"; + reserved "supports_lossless_audio"; + reserved 1; + reserved 4; + reserved 24; + bool can_be_player = 2; + bool restrict_to_local = 3; + bool gaia_eq_connect_id = 5; + bool supports_logout = 6; + bool is_observable = 7; + int32 volume_steps = 8; + repeated string supported_types = 9; + bool command_acks = 10; + bool supports_rename = 11; + bool hidden = 12; + bool disable_volume = 13; + bool connect_disabled = 14; + bool supports_playlist_v2 = 15; + bool is_controllable = 16; + bool supports_external_episodes = 17; + bool supports_set_backend_metadata = 18; + bool supports_transfer_command = 19; + bool supports_command_request = 20; + bool is_voice_enabled = 21; + bool needs_full_player_state = 22; + bool supports_gzip_pushes = 23; + bool supports_set_options_command = 25; + CapabilitySupportDetails supports_hifi = 26; + string connect_capabilities = 27; + bool supports_rooms = 28; + bool supports_dj = 29; + common.media.AudioQuality supported_audio_quality = 30; +} + +message CapabilitySupportDetails { + bool fully_supported = 1; + bool user_eligible = 2; + bool device_supported = 3; +} + +message ConnectCommandOptions { + int32 message_id = 1; + uint32 target_alias_id = 3; +} + +message ConnectLoggingParams { + repeated string interaction_ids = 1; + repeated string page_instance_ids = 2; +} + +message LogoutCommand { + ConnectCommandOptions command_options = 1; +} + +message SetVolumeCommand { + int32 volume = 1; + ConnectCommandOptions command_options = 2; + ConnectLoggingParams logging_params = 3; + string connection_type = 4; +} + +message RenameCommand { + string rename_to = 1; + ConnectCommandOptions command_options = 2; +} + +message SetBackendMetadataCommand { + map metadata = 1; +} + +enum AudioOutputDeviceType { + UNKNOWN_AUDIO_OUTPUT_DEVICE_TYPE = 0; + BUILT_IN_SPEAKER = 1; + LINE_OUT = 2; + BLUETOOTH = 3; + AIRPLAY = 4; + AUTOMOTIVE = 5; + CAR_PROJECTED = 6; +} + +enum PutStateReason { + UNKNOWN_PUT_STATE_REASON = 0; + SPIRC_HELLO = 1; + SPIRC_NOTIFY = 2; + NEW_DEVICE = 3; + PLAYER_STATE_CHANGED = 4; + VOLUME_CHANGED = 5; + PICKER_OPENED = 6; + BECAME_INACTIVE = 7; + ALIAS_CHANGED = 8; + NEW_CONNECTION = 9; + PULL_PLAYBACK = 10; + AUDIO_DRIVER_INFO_CHANGED = 11; + PUT_STATE_RATE_LIMITED = 12; + BACKEND_METADATA_APPLIED = 13; +} + +enum MemberType { + SPIRC_V2 = 0; + SPIRC_V3 = 1; + CONNECT_STATE = 2; + CONNECT_STATE_EXTENDED = 5; + ACTIVE_DEVICE_TRACKER = 6; + PLAY_TOKEN = 7; +} + +enum ClusterUpdateReason { + UNKNOWN_CLUSTER_UPDATE_REASON = 0; + DEVICES_DISAPPEARED = 1; + DEVICE_STATE_CHANGED = 2; + NEW_DEVICE_APPEARED = 3; + DEVICE_VOLUME_CHANGED = 4; + DEVICE_ALIAS_CHANGED = 5; + DEVICE_NEW_CONNECTION = 6; +} + diff --git a/protocol/proto/connectivity.proto b/protocol/proto/connectivity.proto new file mode 100644 index 00000000..ec85e4f9 --- /dev/null +++ b/protocol/proto/connectivity.proto @@ -0,0 +1,77 @@ +syntax = "proto3"; + +package spotify.clienttoken.data.v0; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.clienttoken.data.v0"; + +message ConnectivitySdkData { + PlatformSpecificData platform_specific_data = 1; + string device_id = 2; +} + +message PlatformSpecificData { + oneof data { + NativeAndroidData android = 1; + NativeIOSData ios = 2; + NativeDesktopMacOSData desktop_macos = 3; + NativeDesktopWindowsData desktop_windows = 4; + NativeDesktopLinuxData desktop_linux = 5; + } +} + +message NativeAndroidData { + Screen screen_dimensions = 1; + string android_version = 2; + int32 api_version = 3; + string device_name = 4; + string model_str = 5; + string vendor = 6; + string vendor_2 = 7; + int32 unknown_value_8 = 8; +} + +message NativeIOSData { + // https://developer.apple.com/documentation/uikit/uiuserinterfaceidiom + int32 user_interface_idiom = 1; + bool target_iphone_simulator = 2; + string hw_machine = 3; + string system_version = 4; + string simulator_model_identifier = 5; +} + +message NativeDesktopWindowsData { + int32 os_version = 1; + int32 os_build = 3; + // https://docs.microsoft.com/en-us/dotnet/api/system.platformid?view=net-6.0 + int32 platform_id = 4; + int32 unknown_value_5 = 5; + int32 unknown_value_6 = 6; + // https://docs.microsoft.com/en-us/dotnet/api/system.reflection.imagefilemachine?view=net-6.0 + int32 image_file_machine = 7; + // https://docs.microsoft.com/en-us/dotnet/api/system.reflection.portableexecutable.machine?view=net-6.0 + int32 pe_machine = 8; + bool unknown_value_10 = 10; +} + +message NativeDesktopLinuxData { + string system_name = 1; // uname -s + string system_release = 2; // -r + string system_version = 3; // -v + string hardware = 4; // -i +} + +message NativeDesktopMacOSData { + string system_version = 1; + string hw_model = 2; + string compiled_cpu_type = 3; +} + +message Screen { + int32 width = 1; + int32 height = 2; + int32 density = 3; + int32 unknown_value_4 = 4; + int32 unknown_value_5 = 5; +} diff --git a/protocol/proto/contains_request.proto b/protocol/proto/contains_request.proto new file mode 100644 index 00000000..52cc7382 --- /dev/null +++ b/protocol/proto/contains_request.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message ContainsRequest { + repeated string items = 1; +} + +message ContainsResponse { + repeated bool found = 1; +} diff --git a/protocol/proto/content_access_token_cosmos.proto b/protocol/proto/content_access_token_cosmos.proto new file mode 100644 index 00000000..2c98125b --- /dev/null +++ b/protocol/proto/content_access_token_cosmos.proto @@ -0,0 +1,36 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.contentaccesstoken.proto; + +import "google/protobuf/timestamp.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.contentaccesstoken.proto"; + +message ContentAccessTokenResponse { + Error error = 1; + ContentAccessToken content_access_token = 2; +} + +message ContentAccessToken { + string token = 1; + google.protobuf.Timestamp expires_at = 2; + google.protobuf.Timestamp refresh_at = 3; + repeated string domains = 4; +} + +message ContentAccessRefreshToken { + string token = 1; +} + +message IsEnabledResponse { + bool is_enabled = 1; +} + +message Error { + int32 error_code = 1; + string error_description = 2; +} diff --git a/protocol/proto/context.proto b/protocol/proto/context.proto new file mode 100644 index 00000000..bc1d4722 --- /dev/null +++ b/protocol/proto/context.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_page.proto"; +import "restrictions.proto"; + +option optimize_for = CODE_SIZE; + +message Context { + optional string uri = 1; + optional string url = 2; + map metadata = 3; + optional Restrictions restrictions = 4; + repeated ContextPage pages = 5; + optional bool loading = 6; +} diff --git a/protocol/proto/context_application_desktop.proto b/protocol/proto/context_application_desktop.proto new file mode 100644 index 00000000..a754673c --- /dev/null +++ b/protocol/proto/context_application_desktop.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ApplicationDesktop { + string version_string = 1; + int64 version_code = 2; + bytes session_id = 3; +} diff --git a/protocol/proto/context_client_id.proto b/protocol/proto/context_client_id.proto new file mode 100644 index 00000000..7e116532 --- /dev/null +++ b/protocol/proto/context_client_id.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message ClientId { + bytes value = 1; +} diff --git a/protocol/proto/context_device_desktop.proto b/protocol/proto/context_device_desktop.proto new file mode 100644 index 00000000..8ff3b874 --- /dev/null +++ b/protocol/proto/context_device_desktop.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message DeviceDesktop { + string platform_type = 1; + string device_manufacturer = 2; + string device_model = 3; + string device_id = 4; + string os_version = 5; +} diff --git a/protocol/proto/context_index.proto b/protocol/proto/context_index.proto new file mode 100644 index 00000000..40edd57d --- /dev/null +++ b/protocol/proto/context_index.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message ContextIndex { + optional uint32 page = 1; + optional uint32 track = 2; +} diff --git a/protocol/proto/context_installation_id.proto b/protocol/proto/context_installation_id.proto new file mode 100644 index 00000000..b690db11 --- /dev/null +++ b/protocol/proto/context_installation_id.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message InstallationId { + bytes value = 1; +} diff --git a/protocol/proto/context_monotonic_clock.proto b/protocol/proto/context_monotonic_clock.proto new file mode 100644 index 00000000..c2e807af --- /dev/null +++ b/protocol/proto/context_monotonic_clock.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message MonotonicClock { + int64 id = 1; + int64 value = 2; +} diff --git a/protocol/proto/context_node.proto b/protocol/proto/context_node.proto new file mode 100644 index 00000000..398db427 --- /dev/null +++ b/protocol/proto/context_node.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_processor.proto"; +import "play_origin.proto"; +import "prepare_play_options.proto"; +import "track_instance.proto"; +import "track_instantiator.proto"; +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message ContextNode { + optional TrackInstance current_track = 2; + optional TrackInstantiator instantiate = 3; + optional PreparePlayOptions prepare_options = 4; + optional PlayOrigin play_origin = 5; + optional ContextProcessor context_processor = 6; + optional string session_id = 7; + optional sint32 iteration = 8; + optional bool pending_pause = 9; + optional ContextTrack injected_connect_transfer_track = 10; +} diff --git a/protocol/proto/context_page.proto b/protocol/proto/context_page.proto new file mode 100644 index 00000000..c3c7f9a4 --- /dev/null +++ b/protocol/proto/context_page.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message ContextPage { + optional string page_url = 1; + optional string next_page_url = 2; + map metadata = 3; + repeated ContextTrack tracks = 4; + optional bool loading = 5; +} diff --git a/protocol/proto/context_player_options.proto b/protocol/proto/context_player_options.proto new file mode 100644 index 00000000..1dca4360 --- /dev/null +++ b/protocol/proto/context_player_options.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message ContextPlayerOptions { + optional bool shuffling_context = 1; + optional bool repeating_context = 2; + optional bool repeating_track = 3; + optional float playback_speed = 4; + map modes = 5; +} + +message ContextPlayerOptionOverrides { + optional bool shuffling_context = 1; + optional bool repeating_context = 2; + optional bool repeating_track = 3; + optional float playback_speed = 4; + map modes = 5; +} + diff --git a/protocol/proto/context_processor.proto b/protocol/proto/context_processor.proto new file mode 100644 index 00000000..fbfa1c30 --- /dev/null +++ b/protocol/proto/context_processor.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context.proto"; +import "context_view.proto"; +import "skip_to_track.proto"; + +option optimize_for = CODE_SIZE; + +message ContextProcessor { + optional Context context = 1; + optional context_view.proto.ContextView context_view = 2; + optional SkipToTrack pending_skip_to = 3; + optional string shuffle_seed = 4; + optional int32 index = 5; +} diff --git a/protocol/proto/context_sdk.proto b/protocol/proto/context_sdk.proto new file mode 100644 index 00000000..fa3b0f71 --- /dev/null +++ b/protocol/proto/context_sdk.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Sdk { + string version_name = 1; + string type = 2; +} diff --git a/protocol/proto/context_time.proto b/protocol/proto/context_time.proto new file mode 100644 index 00000000..009cad10 --- /dev/null +++ b/protocol/proto/context_time.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message Time { + int64 value = 1; +} diff --git a/protocol/proto/context_track.proto b/protocol/proto/context_track.proto new file mode 100644 index 00000000..12fe06f5 --- /dev/null +++ b/protocol/proto/context_track.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message ContextTrack { + optional string uri = 1; + optional string uid = 2; + optional bytes gid = 3; + map metadata = 4; +} diff --git a/protocol/proto/context_view.proto b/protocol/proto/context_view.proto new file mode 100644 index 00000000..63efd004 --- /dev/null +++ b/protocol/proto/context_view.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.context_view.proto; + +import "context_track.proto"; +import "context_view_cyclic_list.proto"; + +option optimize_for = CODE_SIZE; + +message ContextView { + map patch_map = 1; + optional uint32 iteration_size = 2; + optional cyclic_list.proto.CyclicEntryKeyList cyclic_list = 3; +} + diff --git a/protocol/proto/context_view_cyclic_list.proto b/protocol/proto/context_view_cyclic_list.proto new file mode 100644 index 00000000..56ace3e2 --- /dev/null +++ b/protocol/proto/context_view_cyclic_list.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.context_view.cyclic_list.proto; + +import "context_view_entry_key.proto"; + +option optimize_for = CODE_SIZE; + +message Instance { + optional context_view.proto.EntryKey item = 1; + optional int32 iteration = 2; +} + +message Patch { + optional int32 start = 1; + optional int32 end = 2; + repeated Instance instances = 3; +} + +message CyclicEntryKeyList { + optional context_view.proto.EntryKey delimiter = 1; + repeated context_view.proto.EntryKey items = 2; + optional Patch patch = 3; +} diff --git a/protocol/proto/context_view_entry.proto b/protocol/proto/context_view_entry.proto new file mode 100644 index 00000000..23c9ce2e --- /dev/null +++ b/protocol/proto/context_view_entry.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.context_view.proto; + +import "context_index.proto"; +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message Entry { + enum Type { + TRACK = 0; + DELIMITER = 1; + PAGE_PLACEHOLDER = 2; + CONTEXT_PLACEHOLDER = 3; + } + + optional Type type = 1; + optional player.proto.ContextTrack track = 2; + optional player.proto.ContextIndex index = 3; + optional int32 page_index = 4; + optional int32 absolute_index = 5; +} diff --git a/protocol/proto/context_view_entry_key.proto b/protocol/proto/context_view_entry_key.proto new file mode 100644 index 00000000..7cb7a1e6 --- /dev/null +++ b/protocol/proto/context_view_entry_key.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.context_view.proto; + +import "context_view_entry.proto"; + +option optimize_for = CODE_SIZE; + +message EntryKey { + optional Entry.Type type = 1; + optional string data = 2; +} diff --git a/protocol/proto/cosmos_changes_request.proto b/protocol/proto/cosmos_changes_request.proto new file mode 100644 index 00000000..81c81894 --- /dev/null +++ b/protocol/proto/cosmos_changes_request.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.collection_cosmos.changes_request.proto; + +option objc_class_prefix = "SPTCollectionCosmosChanges"; +option optimize_for = CODE_SIZE; + +message Response { +} diff --git a/protocol/proto/cosmos_decorate_request.proto b/protocol/proto/cosmos_decorate_request.proto new file mode 100644 index 00000000..c20c561a --- /dev/null +++ b/protocol/proto/cosmos_decorate_request.proto @@ -0,0 +1,71 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.collection_cosmos.decorate_request.proto; + +import "collection/album_collection_state.proto"; +import "collection/artist_collection_state.proto"; +import "collection/episode_collection_state.proto"; +import "collection/show_collection_state.proto"; +import "collection/track_collection_state.proto"; +import "played_state/episode_played_state.proto"; +import "played_state/show_played_state.proto"; +import "played_state/track_played_state.proto"; +import "sync/album_sync_state.proto"; +import "sync/artist_sync_state.proto"; +import "sync/episode_sync_state.proto"; +import "sync/track_sync_state.proto"; +import "metadata/album_metadata.proto"; +import "metadata/artist_metadata.proto"; +import "metadata/episode_metadata.proto"; +import "metadata/show_metadata.proto"; +import "metadata/track_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosDecorate"; +option optimize_for = CODE_SIZE; + +message Album { + optional cosmos_util.proto.AlbumMetadata album_metadata = 1; + optional cosmos_util.proto.AlbumCollectionState album_collection_state = 2; + optional cosmos_util.proto.AlbumSyncState album_offline_state = 3; + optional string link = 4; +} + +message Artist { + optional cosmos_util.proto.ArtistMetadata artist_metadata = 1; + optional cosmos_util.proto.ArtistCollectionState artist_collection_state = 2; + optional cosmos_util.proto.ArtistSyncState artist_offline_state = 3; + optional string link = 4; +} + +message Episode { + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 2; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 3; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 4; + optional string link = 5; +} + +message Show { + optional cosmos_util.proto.ShowMetadata show_metadata = 1; + optional cosmos_util.proto.ShowCollectionState show_collection_state = 2; + optional cosmos_util.proto.ShowPlayState show_play_state = 3; + optional string link = 4; +} + +message Track { + optional cosmos_util.proto.TrackMetadata track_metadata = 1; + optional cosmos_util.proto.TrackSyncState track_offline_state = 2; + optional cosmos_util.proto.TrackPlayState track_play_state = 3; + optional cosmos_util.proto.TrackCollectionState track_collection_state = 4; + optional string link = 5; +} + +message Response { + repeated Show show = 1; + repeated Episode episode = 2; + repeated Album album = 3; + repeated Artist artist = 4; + repeated Track track = 5; +} diff --git a/protocol/proto/cosmos_get_album_list_request.proto b/protocol/proto/cosmos_get_album_list_request.proto new file mode 100644 index 00000000..83f2ad92 --- /dev/null +++ b/protocol/proto/cosmos_get_album_list_request.proto @@ -0,0 +1,37 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.collection_cosmos.album_list_request.proto; + +import "collection/album_collection_state.proto"; +import "sync/album_sync_state.proto"; +import "metadata/album_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosAlbumList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 add_time = 3; + optional cosmos_util.proto.AlbumMetadata album_metadata = 4; + optional cosmos_util.proto.AlbumCollectionState album_collection_state = 5; + optional cosmos_util.proto.AlbumSyncState album_offline_state = 6; + optional string group_label = 7; +} + +message GroupHeader { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 length = 3; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; + optional string offline = 5; + optional uint32 sync_progress = 6; +} diff --git a/protocol/proto/cosmos_get_artist_list_request.proto b/protocol/proto/cosmos_get_artist_list_request.proto new file mode 100644 index 00000000..1dfeedba --- /dev/null +++ b/protocol/proto/cosmos_get_artist_list_request.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.artist_list_request.proto; + +import "collection/artist_collection_state.proto"; +import "sync/artist_sync_state.proto"; +import "metadata/artist_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosArtistList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 add_time = 3; + optional cosmos_util.proto.ArtistMetadata artist_metadata = 4; + optional cosmos_util.proto.ArtistCollectionState artist_collection_state = 5; + optional cosmos_util.proto.ArtistSyncState artist_offline_state = 6; + optional string group_label = 7; +} + +message GroupHeader { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 length = 3; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; + optional string offline = 5; + optional uint32 sync_progress = 6; + repeated GroupHeader group_index = 7; +} diff --git a/protocol/proto/cosmos_get_episode_list_request.proto b/protocol/proto/cosmos_get_episode_list_request.proto new file mode 100644 index 00000000..72bd7c42 --- /dev/null +++ b/protocol/proto/cosmos_get_episode_list_request.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.collection_cosmos.episode_list_request.proto; + +import "collection/episode_collection_state.proto"; +import "played_state/episode_played_state.proto"; +import "sync/episode_sync_state.proto"; +import "metadata/episode_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosEpisodeList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header = 1; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 5; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; +} diff --git a/protocol/proto/cosmos_get_show_list_request.proto b/protocol/proto/cosmos_get_show_list_request.proto new file mode 100644 index 00000000..e2b8a578 --- /dev/null +++ b/protocol/proto/cosmos_get_show_list_request.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto2"; + +package spotify.collection_cosmos.show_list_request.proto; + +import "collection/show_collection_state.proto"; +import "played_state/show_played_state.proto"; +import "metadata/show_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosShowList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional cosmos_util.proto.ShowMetadata show_metadata = 2; + optional cosmos_util.proto.ShowCollectionState show_collection_state = 3; + optional cosmos_util.proto.ShowPlayState show_play_state = 4; + optional uint32 headerless_index = 5; + optional uint32 add_time = 6; + optional bool has_new_episodes = 7; + optional uint64 latest_published_episode_date = 8; +} + +message Response { + repeated Item item = 1; + optional uint32 num_offlined_episodes = 2; + optional uint32 unfiltered_length = 3; + optional uint32 unranged_length = 4; + optional bool loading_contents = 5; +} diff --git a/protocol/proto/cosmos_get_tags_info_request.proto b/protocol/proto/cosmos_get_tags_info_request.proto new file mode 100644 index 00000000..d1a34956 --- /dev/null +++ b/protocol/proto/cosmos_get_tags_info_request.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection_cosmos.tags_info_request.proto; + +option objc_class_prefix = "SPTCollectionCosmosTagsInfo"; +option optimize_for = CODE_SIZE; + +message Request { +} + +message Response { + bool is_synced = 1; +} diff --git a/protocol/proto/cosmos_get_track_list_metadata_request.proto b/protocol/proto/cosmos_get_track_list_metadata_request.proto new file mode 100644 index 00000000..2399656c --- /dev/null +++ b/protocol/proto/cosmos_get_track_list_metadata_request.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.collection_cosmos.proto; + +option objc_class_prefix = "SPTCollectionCosmos"; +option optimize_for = CODE_SIZE; + +message TrackListMetadata { + optional uint32 unfiltered_length = 1; + optional uint32 length = 2; + optional string offline = 3; + optional uint32 sync_progress = 4; +} diff --git a/protocol/proto/cosmos_get_track_list_request.proto b/protocol/proto/cosmos_get_track_list_request.proto new file mode 100644 index 00000000..21eea1b1 --- /dev/null +++ b/protocol/proto/cosmos_get_track_list_request.proto @@ -0,0 +1,41 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.collection_cosmos.track_list_request.proto; + +import "collection/artist_collection_state.proto"; +import "collection/track_collection_state.proto"; +import "played_state/track_played_state.proto"; +import "sync/track_sync_state.proto"; +import "metadata/track_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosTrackList"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 add_time = 3; + optional cosmos_util.proto.TrackMetadata track_metadata = 4; + optional cosmos_util.proto.TrackSyncState track_offline_state = 5; + optional cosmos_util.proto.TrackPlayState track_play_state = 6; + optional cosmos_util.proto.TrackCollectionState track_collection_state = 7; + optional string group_label = 8; + repeated cosmos_util.proto.ArtistCollectionState artist_collection_state = 9; +} + +message GroupHeader { + optional string header_field = 1; + optional uint32 index = 2; + optional uint32 length = 3; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; + optional string offline = 5; + optional uint32 sync_progress = 6; +} diff --git a/protocol/proto/cosmos_get_unplayed_episodes_request.proto b/protocol/proto/cosmos_get_unplayed_episodes_request.proto new file mode 100644 index 00000000..691761b3 --- /dev/null +++ b/protocol/proto/cosmos_get_unplayed_episodes_request.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.collection_cosmos.unplayed_request.proto; + +import "collection/episode_collection_state.proto"; +import "played_state/episode_played_state.proto"; +import "sync/episode_sync_state.proto"; +import "metadata/episode_metadata.proto"; + +option objc_class_prefix = "SPTCollectionCosmosUnplayedEpisodes"; +option optimize_for = CODE_SIZE; + +message Item { + optional string header = 1; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 5; +} + +message Response { + repeated Item item = 1; + optional uint32 unfiltered_length = 2; + optional uint32 unranged_length = 3; + optional bool loading_contents = 4; +} diff --git a/protocol/proto/cuepoints.proto b/protocol/proto/cuepoints.proto new file mode 100644 index 00000000..77a01a67 --- /dev/null +++ b/protocol/proto/cuepoints.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.automix.proto; + +option optimize_for = CODE_SIZE; + +message Cuepoint { + int64 position_ms = 1; + float tempo_bpm = 2; + Origin origin = 3; +} + +message Cuepoints { + Cuepoint fade_in_cuepoint = 1; + Cuepoint fade_out_cuepoint = 2; +} + +enum Origin { + HUMAN = 0; + ML = 1; +} diff --git a/protocol/proto/decorate_request.proto b/protocol/proto/decorate_request.proto new file mode 100644 index 00000000..4d56f65b --- /dev/null +++ b/protocol/proto/decorate_request.proto @@ -0,0 +1,47 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.show_cosmos.decorate_request.proto; + +import "metadata/episode_metadata.proto"; +import "metadata/show_metadata.proto"; +import "played_state/episode_played_state.proto"; +import "played_state/show_played_state.proto"; +import "show_episode_state.proto"; +import "show_show_state.proto"; +import "show_offline_state.proto"; + +option objc_class_prefix = "SPTShowCosmosDecorate"; +option optimize_for = CODE_SIZE; + +message Show { + reserved 5; + reserved 6; + optional cosmos_util.proto.ShowMetadata show_metadata = 1; + optional show_cosmos.proto.ShowCollectionState show_collection_state = 2; + optional cosmos_util.proto.ShowPlayState show_play_state = 3; + optional string link = 4; + optional show_cosmos.proto.ShowOfflineState show_offline_state = 7; +} + +message Episode { + reserved 6; + reserved 7; + reserved 8; + reserved 9; + reserved 10; + reserved 11; + reserved 12; + reserved 13; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1; + optional show_cosmos.proto.EpisodeCollectionState episode_collection_state = 2; + optional show_cosmos.proto.EpisodeOfflineState episode_offline_state = 3; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 4; + optional string link = 5; +} + +message Response { + repeated Show show = 1; + repeated Episode episode = 2; +} diff --git a/protocol/proto/devices.proto b/protocol/proto/devices.proto new file mode 100644 index 00000000..00deaa4d --- /dev/null +++ b/protocol/proto/devices.proto @@ -0,0 +1,35 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.connectstate.devices; + +option java_package = "com.spotify.common.proto"; + +message DeviceAlias { + uint32 id = 1; + string display_name = 2; + bool is_group = 3; +} + +enum DeviceType { + UNKNOWN = 0; + COMPUTER = 1; + TABLET = 2; + SMARTPHONE = 3; + SPEAKER = 4; + TV = 5; + AVR = 6; + STB = 7; + AUDIO_DONGLE = 8; + GAME_CONSOLE = 9; + CAST_VIDEO = 10; + CAST_AUDIO = 11; + AUTOMOBILE = 12; + SMARTWATCH = 13; + CHROMEBOOK = 14; + UNKNOWN_SPOTIFY = 100; + CAR_THING = 101; + OBSERVER = 102; + HOME_THING = 103; +} diff --git a/protocol/proto/display_segments.proto b/protocol/proto/display_segments.proto new file mode 100644 index 00000000..eb3e02b3 --- /dev/null +++ b/protocol/proto/display_segments.proto @@ -0,0 +1,40 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments.display; + +import "podcast_segments.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "DisplaySegmentsProto"; +option java_package = "com.spotify.podcastsegments.display.proto"; + +message DisplaySegments { + repeated DisplaySegment display_segments = 1; + bool can_upsell = 2; + string album_mosaic_uri = 3; + repeated string artists = 4; + int32 duration_ms = 5; +} + +message DisplaySegment { + string uri = 1; + int32 absolute_start_ms = 2; + int32 absolute_stop_ms = 3; + + Source source = 4; + enum Source { + PLAYBACK = 0; + EMBEDDED = 1; + } + + SegmentType type = 5; + string title = 6; + string subtitle = 7; + string image_url = 8; + string action_url = 9; + bool is_abridged = 10; +} diff --git a/protocol/proto/display_segments_extension.proto b/protocol/proto/display_segments_extension.proto new file mode 100644 index 00000000..55914bd7 --- /dev/null +++ b/protocol/proto/display_segments_extension.proto @@ -0,0 +1,47 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.displaysegments.v1; + +option objc_class_prefix = "ESP"; +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; + PodcastChaptersDecoration podcast_chapters_decoration = 5; + } +} + +message DisplaySegment { + string uri = 1; + SegmentType type = 2; + int32 duration_ms = 3; + int32 seek_start_ms = 4; + int32 seek_stop_ms = 5; + optional string title = 6; + optional string subtitle = 7; + optional string image_url = 8; + optional bool is_preview = 9; +} + +message MusicAndTalkDecoration { + bool can_upsell = 1; +} + +message PodcastChaptersDecoration { + repeated string tags = 1; +} + +enum SegmentType { + SEGMENT_TYPE_UNSPECIFIED = 0; + SEGMENT_TYPE_TALK = 1; + SEGMENT_TYPE_MUSIC = 2; +} diff --git a/protocol/proto/entity_extension_data.proto b/protocol/proto/entity_extension_data.proto new file mode 100644 index 00000000..589ee687 --- /dev/null +++ b/protocol/proto/entity_extension_data.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.extendedmetadata; + +import "google/protobuf/any.proto"; + +option cc_enable_arenas = true; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.extendedmetadata.proto"; + +message EntityExtensionDataHeader { + int32 status_code = 1; + string etag = 2; + string locale = 3; + int64 cache_ttl_in_seconds = 4; + int64 offline_ttl_in_seconds = 5; +} + +message EntityExtensionData { + EntityExtensionDataHeader header = 1; + string entity_uri = 2; + google.protobuf.Any extension_data = 3; +} + +message PlainListAssoc { + repeated string entity_uri = 1; +} + +message AssocHeader { +} + +message Assoc { + AssocHeader header = 1; + PlainListAssoc plain_list = 2; +} diff --git a/protocol/proto/es_add_to_queue_request.proto b/protocol/proto/es_add_to_queue_request.proto new file mode 100644 index 00000000..dd0e009b --- /dev/null +++ b/protocol/proto/es_add_to_queue_request.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_context_track.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message AddToQueueRequest { + ContextTrack track = 1; + CommandOptions options = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_command_options.proto b/protocol/proto/es_command_options.proto new file mode 100644 index 00000000..8f6c5bc1 --- /dev/null +++ b/protocol/proto/es_command_options.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message CommandOptions { + bool override_restrictions = 1; + bool only_for_local_device = 2; + bool system_initiated = 3; + bytes only_for_playback_id = 4; +} diff --git a/protocol/proto/es_context.proto b/protocol/proto/es_context.proto new file mode 100644 index 00000000..19e1f885 --- /dev/null +++ b/protocol/proto/es_context.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_page.proto"; +import "es_restrictions.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message Context { + repeated ContextPage pages = 1; + map metadata = 2; + string uri = 3; + string url = 4; + bool is_loading = 5; + Restrictions restrictions = 6; +} diff --git a/protocol/proto/es_context_page.proto b/protocol/proto/es_context_page.proto new file mode 100644 index 00000000..28776cfd --- /dev/null +++ b/protocol/proto/es_context_page.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextPage { + repeated ContextTrack tracks = 1; + map metadata = 2; + string page_url = 3; + string next_page_url = 4; + bool is_loading = 5; +} diff --git a/protocol/proto/es_context_player_error.proto b/protocol/proto/es_context_player_error.proto new file mode 100644 index 00000000..bd6ded16 --- /dev/null +++ b/protocol/proto/es_context_player_error.proto @@ -0,0 +1,61 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextPlayerError { + enum ErrorCode { + SUCCESS = 0; + PLAYBACK_STUCK = 1; + PLAYBACK_ERROR = 2; + LICENSE_CHANGE = 3; + PLAY_RESTRICTED = 4; + STOP_RESTRICTED = 5; + UPDATE_RESTRICTED = 6; + PAUSE_RESTRICTED = 7; + RESUME_RESTRICTED = 8; + SKIP_TO_PREV_RESTRICTED = 9; + SKIP_TO_NEXT_RESTRICTED = 10; + SKIP_TO_NON_EXISTENT_TRACK = 11; + SEEK_TO_RESTRICTED = 12; + TOGGLE_REPEAT_CONTEXT_RESTRICTED = 13; + TOGGLE_REPEAT_TRACK_RESTRICTED = 14; + SET_OPTIONS_RESTRICTED = 15; + TOGGLE_SHUFFLE_RESTRICTED = 16; + SET_QUEUE_RESTRICTED = 17; + INTERRUPT_PLAYBACK_RESTRICTED = 18; + ONE_TRACK_UNPLAYABLE = 19; + ONE_TRACK_UNPLAYABLE_AUTO_STOPPED = 20; + ALL_TRACKS_UNPLAYABLE_AUTO_STOPPED = 21; + SKIP_TO_NON_EXISTENT_TRACK_AUTO_STOPPED = 22; + QUEUE_REVISION_MISMATCH = 23; + VIDEO_PLAYBACK_ERROR = 24; + VIDEO_GEOGRAPHICALLY_RESTRICTED = 25; + VIDEO_UNSUPPORTED_PLATFORM_VERSION = 26; + VIDEO_UNSUPPORTED_CLIENT_VERSION = 27; + VIDEO_UNSUPPORTED_KEY_SYSTEM = 28; + VIDEO_MANIFEST_DELETED = 29; + VIDEO_COUNTRY_RESTRICTED = 30; + VIDEO_UNAVAILABLE = 31; + VIDEO_CATALOGUE_RESTRICTED = 32; + INVALID = 33; + TIMEOUT = 34; + PLAYBACK_REPORTING_ERROR = 35; + UNKNOWN = 36; + ADD_TO_QUEUE_RESTRICTED = 37; + PICK_AND_SHUFFLE_CAPPED = 38; + PICK_AND_SHUFFLE_CONNECT_RESTRICTED = 39; + CONTEXT_LOADING_FAILED = 40; + AUDIOBOOK_NOT_PLAYABLE = 41; + SIGNAL_NOT_AVAILABLE = 42; + } + + ErrorCode code = 1; + string message = 2; + map data = 3; +} diff --git a/protocol/proto/es_context_player_options.proto b/protocol/proto/es_context_player_options.proto new file mode 100644 index 00000000..337f8596 --- /dev/null +++ b/protocol/proto/es_context_player_options.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextPlayerOptions { + bool shuffling_context = 1; + bool repeating_context = 2; + bool repeating_track = 3; + map modes = 5; + optional float playback_speed = 4; +} + +message ContextPlayerOptionOverrides { + OptionalBoolean shuffling_context = 1; + OptionalBoolean repeating_context = 2; + OptionalBoolean repeating_track = 3; + map modes = 5; + optional float playback_speed = 4; +} diff --git a/protocol/proto/es_context_player_state.proto b/protocol/proto/es_context_player_state.proto new file mode 100644 index 00000000..c0023411 --- /dev/null +++ b/protocol/proto/es_context_player_state.proto @@ -0,0 +1,85 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_restrictions.proto"; +import "es_play_origin.proto"; +import "es_optional.proto"; +import "es_provided_track.proto"; +import "es_context_player_options.proto"; +import "es_prepare_play_options.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextIndex { + uint64 page = 1; + uint64 track = 2; +} + +message PlaybackQuality { + enum BitrateLevel { + UNKNOWN = 0; + LOW = 1; + NORMAL = 2; + HIGH = 3; + VERY_HIGH = 4; + HIFI = 5; + HIFI24 = 6; + } + + enum BitrateStrategy { + UNKNOWN_STRATEGY = 0; + BEST_MATCHING = 1; + BACKEND_ADVISED = 2; + OFFLINED_FILE = 3; + CACHED_FILE = 4; + LOCAL_FILE = 5; + } + + enum HiFiStatus { + NONE = 0; + OFF = 1; + ON = 2; + } + + BitrateLevel bitrate_level = 1; + BitrateStrategy strategy = 2; + BitrateLevel target_bitrate_level = 3; + bool target_bitrate_available = 4; + HiFiStatus hifi_status = 5; +} + +message ContextPlayerState { + uint64 timestamp = 1; + string context_uri = 2; + string context_url = 3; + Restrictions context_restrictions = 4; + PlayOrigin play_origin = 5; + ContextIndex index = 6; + ProvidedTrack track = 7; + bytes playback_id = 8; + PlaybackQuality playback_quality = 9; + OptionalDouble playback_speed = 10; + OptionalInt64 position_as_of_timestamp = 11; + OptionalInt64 duration = 12; + bool is_playing = 13; + bool is_paused = 14; + bool is_buffering = 15; + bool is_system_initiated = 16; + ContextPlayerOptions options = 17; + Restrictions restrictions = 18; + repeated string suppressions = 19; + repeated ProvidedTrack prev_tracks = 20; + repeated ProvidedTrack next_tracks = 21; + map context_metadata = 22; + map page_metadata = 23; + string session_id = 24; + uint64 queue_revision = 25; + PreparePlayOptions.AudioStream audio_stream = 26; + repeated string signals = 27; + string session_command_id = 28; +} diff --git a/protocol/proto/es_context_track.proto b/protocol/proto/es_context_track.proto new file mode 100644 index 00000000..c80e29ac --- /dev/null +++ b/protocol/proto/es_context_track.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ContextTrack { + string uri = 1; + string uid = 2; + map metadata = 3; +} diff --git a/protocol/proto/es_delete_session.proto b/protocol/proto/es_delete_session.proto new file mode 100644 index 00000000..140f62a4 --- /dev/null +++ b/protocol/proto/es_delete_session.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message DeleteSessionRequest { + string session_id = 1; +} + +message DeleteSessionResponse { +} diff --git a/protocol/proto/es_get_error_request.proto b/protocol/proto/es_get_error_request.proto new file mode 100644 index 00000000..f62feff8 --- /dev/null +++ b/protocol/proto/es_get_error_request.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetErrorRequest { +} diff --git a/protocol/proto/es_get_play_history.proto b/protocol/proto/es_get_play_history.proto new file mode 100644 index 00000000..e887b3c4 --- /dev/null +++ b/protocol/proto/es_get_play_history.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetPlayHistoryRequest { +} + +message GetPlayHistoryResponse { + repeated ContextTrack tracks = 1; +} diff --git a/protocol/proto/es_get_position_state.proto b/protocol/proto/es_get_position_state.proto new file mode 100644 index 00000000..4018d996 --- /dev/null +++ b/protocol/proto/es_get_position_state.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetPositionStateRequest { +} + +message GetPositionStateResponse { + enum Error { + OK = 0; + NOT_FOUND = 1; + } + + Error error = 1; + uint64 timestamp = 2; + uint64 position = 3; + double playback_speed = 4; +} diff --git a/protocol/proto/es_get_queue_request.proto b/protocol/proto/es_get_queue_request.proto new file mode 100644 index 00000000..65a2a24a --- /dev/null +++ b/protocol/proto/es_get_queue_request.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetQueueRequest { +} diff --git a/protocol/proto/es_get_state_request.proto b/protocol/proto/es_get_state_request.proto new file mode 100644 index 00000000..b4d29dce --- /dev/null +++ b/protocol/proto/es_get_state_request.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message GetStateRequest { + OptionalInt64 prev_tracks_cap = 1; + OptionalInt64 next_tracks_cap = 2; +} diff --git a/protocol/proto/es_ident.proto b/protocol/proto/es_ident.proto new file mode 100644 index 00000000..6c52abc2 --- /dev/null +++ b/protocol/proto/es_ident.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connectivity.pubsub.esperanto.proto; + +option java_package = "com.spotify.connectivity.pubsub.esperanto.proto"; + +message Ident { + string Ident = 1; +} diff --git a/protocol/proto/es_ident_filter.proto b/protocol/proto/es_ident_filter.proto new file mode 100644 index 00000000..19ccee40 --- /dev/null +++ b/protocol/proto/es_ident_filter.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connectivity.pubsub.esperanto.proto; + +option java_package = "com.spotify.connectivity.pubsub.esperanto.proto"; + +message IdentFilter { + string Prefix = 1; +} diff --git a/protocol/proto/es_logging_params.proto b/protocol/proto/es_logging_params.proto new file mode 100644 index 00000000..c508cba2 --- /dev/null +++ b/protocol/proto/es_logging_params.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message LoggingParams { + OptionalInt64 command_initiated_time = 1; + OptionalInt64 command_received_time = 2; + repeated string page_instance_ids = 3; + repeated string interaction_ids = 4; +} diff --git a/protocol/proto/es_optional.proto b/protocol/proto/es_optional.proto new file mode 100644 index 00000000..2f75c54c --- /dev/null +++ b/protocol/proto/es_optional.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message OptionalInt64 { + int64 value = 1; +} + +message OptionalDouble { + double value = 1; +} + +message OptionalBoolean { + bool value = 1; +} diff --git a/protocol/proto/es_pause.proto b/protocol/proto/es_pause.proto new file mode 100644 index 00000000..7e8c4955 --- /dev/null +++ b/protocol/proto/es_pause.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_pauseresume_origin.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PauseRequest { + CommandOptions options = 1; + LoggingParams logging_params = 2; + PauseResumeOrigin pause_origin = 3; +} diff --git a/protocol/proto/es_pauseresume_origin.proto b/protocol/proto/es_pauseresume_origin.proto new file mode 100644 index 00000000..20446a20 --- /dev/null +++ b/protocol/proto/es_pauseresume_origin.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option java_package = "com.spotify.player.esperanto.proto"; +option objc_class_prefix = "ESP"; + +message PauseResumeOrigin { + string feature_identifier = 1; +} + diff --git a/protocol/proto/es_play.proto b/protocol/proto/es_play.proto new file mode 100644 index 00000000..ed7196ca --- /dev/null +++ b/protocol/proto/es_play.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_play_options.proto"; +import "es_prepare_play.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PlayRequest { + PreparePlayRequest prepare_play_request = 1; + PlayOptions play_options = 2; + CommandOptions options = 3; + LoggingParams logging_params = 4; +} + +message PlayPreparedRequest { + string session_id = 1; + PlayOptions play_options = 2; + CommandOptions options = 3; + LoggingParams logging_params = 4; +} diff --git a/protocol/proto/es_play_options.proto b/protocol/proto/es_play_options.proto new file mode 100644 index 00000000..f068921b --- /dev/null +++ b/protocol/proto/es_play_options.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PlayOptions { + Reason reason = 1; + enum Reason { + INTERACTIVE = 0; + REMOTE_TRANSFER = 1; + LICENSE_CHANGE = 2; + } + + Operation operation = 2; + enum Operation { + REPLACE = 0; + ENQUEUE = 1; + PUSH = 2; + } + + Trigger trigger = 3; + enum Trigger { + IMMEDIATELY = 0; + ADVANCED_PAST_TRACK = 1; + ADVANCED_PAST_CONTEXT = 2; + } +} diff --git a/protocol/proto/es_play_origin.proto b/protocol/proto/es_play_origin.proto new file mode 100644 index 00000000..c9d8004a --- /dev/null +++ b/protocol/proto/es_play_origin.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PlayOrigin { + string feature_identifier = 1; + string feature_version = 2; + string view_uri = 3; + string external_referrer = 4; + string referrer_identifier = 5; + string device_identifier = 6; + repeated string feature_classes = 7; + string restriction_identifier = 8; +} diff --git a/protocol/proto/es_prefs.proto b/protocol/proto/es_prefs.proto new file mode 100644 index 00000000..220d8942 --- /dev/null +++ b/protocol/proto/es_prefs.proto @@ -0,0 +1,51 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.prefs.esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.prefs.esperanto.proto"; + +service Prefs { + rpc Get(GetParams) returns (PrefValues); + rpc Sub(SubParams) returns (stream PrefValues); + rpc GetAll(GetAllParams) returns (PrefValues); + rpc SubAll(SubAllParams) returns (stream PrefValues); + rpc Set(SetParams) returns (PrefValues); + rpc Create(CreateParams) returns (PrefValues); +} + +message GetParams { + string key = 1; +} + +message SubParams { + string key = 1; +} + +message GetAllParams { +} + +message SubAllParams { +} + +message Value { + oneof value { + int64 number = 1; + bool bool = 2; + string string = 3; + } +} + +message SetParams { + map entries = 1; +} + +message CreateParams { + map entries = 1; +} + +message PrefValues { + map entries = 1; +} diff --git a/protocol/proto/es_prepare_play.proto b/protocol/proto/es_prepare_play.proto new file mode 100644 index 00000000..bee7d510 --- /dev/null +++ b/protocol/proto/es_prepare_play.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context.proto"; +import "es_play_origin.proto"; +import "es_prepare_play_options.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PreparePlayRequest { + Context context = 1; + PreparePlayOptions options = 2; + PlayOrigin play_origin = 3; +} diff --git a/protocol/proto/es_prepare_play_options.proto b/protocol/proto/es_prepare_play_options.proto new file mode 100644 index 00000000..de179814 --- /dev/null +++ b/protocol/proto/es_prepare_play_options.proto @@ -0,0 +1,40 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_player_options.proto"; +import "es_optional.proto"; +import "es_skip_to_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message PreparePlayOptions { + bytes playback_id = 1; + bool always_play_something = 2; + SkipToTrack skip_to = 3; + OptionalInt64 seek_to = 4; + bool initially_paused = 5; + bool system_initiated = 6; + ContextPlayerOptionOverrides player_options_override = 7; + repeated string suppressions = 8; + + PrefetchLevel prefetch_level = 9; + enum PrefetchLevel { + NONE = 0; + MEDIA = 1; + } + + AudioStream audio_stream = 10; + enum AudioStream { + DEFAULT = 0; + ALARM = 1; + } + + string session_id = 11; + string license = 12; + map configuration_override = 13; +} diff --git a/protocol/proto/es_provided_track.proto b/protocol/proto/es_provided_track.proto new file mode 100644 index 00000000..76e86c4c --- /dev/null +++ b/protocol/proto/es_provided_track.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ProvidedTrack { + ContextTrack context_track = 1; + repeated string removed = 2; + repeated string blocked = 3; + string provider = 4; +} diff --git a/protocol/proto/es_pushed_message.proto b/protocol/proto/es_pushed_message.proto new file mode 100644 index 00000000..dd054f5f --- /dev/null +++ b/protocol/proto/es_pushed_message.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connectivity.pubsub.esperanto.proto; + +import "es_ident.proto"; + +option java_package = "com.spotify.connectivity.pubsub.esperanto.proto"; + +message PushedMessage { + Ident Ident = 1; + repeated string Payloads = 2; + map Attributes = 3; +} diff --git a/protocol/proto/es_queue.proto b/protocol/proto/es_queue.proto new file mode 100644 index 00000000..2f7a7b67 --- /dev/null +++ b/protocol/proto/es_queue.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_provided_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message Queue { + uint64 queue_revision = 1; + ProvidedTrack track = 2; + repeated ProvidedTrack next_tracks = 3; + repeated ProvidedTrack prev_tracks = 4; +} diff --git a/protocol/proto/es_remote_config.proto b/protocol/proto/es_remote_config.proto new file mode 100644 index 00000000..adf31a39 --- /dev/null +++ b/protocol/proto/es_remote_config.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.remote_config.esperanto.proto; + +import "esperanto_options.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.remoteconfig.esperanto.proto"; + +service RemoteConfig { + rpc lookupBool(LookupRequest) returns (.spotify.remote_config.esperanto.proto.BoolResponse) {} + rpc lookupInt(LookupRequest) returns (.spotify.remote_config.esperanto.proto.IntResponse) {} + rpc lookupEnum(LookupRequest) returns (.spotify.remote_config.esperanto.proto.EnumResponse) {} +} + +message LookupRequest { + string scope = 1; + string name = 2; +} + +message BoolResponse { + optional bool value = 1; +} + +message IntResponse { + optional int32 value = 1; +} + +message EnumResponse { + optional string value = 1; +} diff --git a/protocol/proto/es_request_info.proto b/protocol/proto/es_request_info.proto new file mode 100644 index 00000000..91a659c9 --- /dev/null +++ b/protocol/proto/es_request_info.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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; + string protocol = 14; + int64 event_redirects_done = 15; +} diff --git a/protocol/proto/es_response_with_reasons.proto b/protocol/proto/es_response_with_reasons.proto new file mode 100644 index 00000000..d12fcd25 --- /dev/null +++ b/protocol/proto/es_response_with_reasons.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ResponseWithReasons { + enum Error { + OK = 0; + FORBIDDEN = 1; + NOT_FOUND = 2; + CONFLICT = 3; + } + + Error error = 1; + string reasons = 2; +} diff --git a/protocol/proto/es_restrictions.proto b/protocol/proto/es_restrictions.proto new file mode 100644 index 00000000..225cc9c2 --- /dev/null +++ b/protocol/proto/es_restrictions.proto @@ -0,0 +1,45 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message ModeRestrictions { + map values = 1; +} + +message RestrictionReasons { + repeated string reasons = 1; +} + +message Restrictions { + repeated string disallow_pausing_reasons = 1; + repeated string disallow_resuming_reasons = 2; + repeated string disallow_seeking_reasons = 3; + repeated string disallow_peeking_prev_reasons = 4; + repeated string disallow_peeking_next_reasons = 5; + repeated string disallow_skipping_prev_reasons = 6; + repeated string disallow_skipping_next_reasons = 7; + repeated string disallow_toggling_repeat_context_reasons = 8; + repeated string disallow_toggling_repeat_track_reasons = 9; + repeated string disallow_toggling_shuffle_reasons = 10; + repeated string disallow_set_queue_reasons = 11; + repeated string disallow_interrupting_playback_reasons = 12; + repeated string disallow_transferring_playback_reasons = 13; + repeated string disallow_remote_control_reasons = 14; + repeated string disallow_inserting_into_next_tracks_reasons = 15; + repeated string disallow_inserting_into_context_tracks_reasons = 16; + repeated string disallow_reordering_in_next_tracks_reasons = 17; + repeated string disallow_reordering_in_context_tracks_reasons = 18; + repeated string disallow_removing_from_next_tracks_reasons = 19; + repeated string disallow_removing_from_context_tracks_reasons = 20; + repeated string disallow_updating_context_reasons = 21; + repeated string disallow_add_to_queue_reasons = 22; + repeated string disallow_setting_playback_speed_reasons = 23; + map disallow_setting_modes = 25; + map disallow_signals = 26; +} diff --git a/protocol/proto/es_resume.proto b/protocol/proto/es_resume.proto new file mode 100644 index 00000000..257a5b9f --- /dev/null +++ b/protocol/proto/es_resume.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_pauseresume_origin.proto"; + +message ResumeRequest { + CommandOptions options = 1; + LoggingParams logging_params = 2; + PauseResumeOrigin resume_origin = 3; +} diff --git a/protocol/proto/es_seek_to.proto b/protocol/proto/es_seek_to.proto new file mode 100644 index 00000000..a185a7d9 --- /dev/null +++ b/protocol/proto/es_seek_to.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SeekToRequest { + enum Relative { + BEGINNING = 0; + END = 1; + CURRENT = 2; + } + + CommandOptions options = 1; + LoggingParams logging_params = 2; + int64 position = 3; + Relative relative = 4; +} + diff --git a/protocol/proto/es_session_response.proto b/protocol/proto/es_session_response.proto new file mode 100644 index 00000000..26f599b4 --- /dev/null +++ b/protocol/proto/es_session_response.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SessionResponse { + string session_id = 1; +} diff --git a/protocol/proto/es_set_options.proto b/protocol/proto/es_set_options.proto new file mode 100644 index 00000000..f4c2f438 --- /dev/null +++ b/protocol/proto/es_set_options.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetOptionsRequest { + OptionalBoolean repeating_track = 1; + OptionalBoolean repeating_context = 2; + OptionalBoolean shuffling_context = 3; + CommandOptions options = 4; + LoggingParams logging_params = 5; + map modes = 7; + optional float playback_speed = 6; +} diff --git a/protocol/proto/es_set_queue_request.proto b/protocol/proto/es_set_queue_request.proto new file mode 100644 index 00000000..7cc6dd17 --- /dev/null +++ b/protocol/proto/es_set_queue_request.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_provided_track.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetQueueRequest { + repeated ProvidedTrack next_tracks = 1; + repeated ProvidedTrack prev_tracks = 2; + uint64 queue_revision = 3; + CommandOptions options = 4; + LoggingParams logging_params = 5; +} diff --git a/protocol/proto/es_set_repeating_context.proto b/protocol/proto/es_set_repeating_context.proto new file mode 100644 index 00000000..2f2d6571 --- /dev/null +++ b/protocol/proto/es_set_repeating_context.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetRepeatingContextRequest { + bool repeating_context = 1; + CommandOptions options = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_set_repeating_track.proto b/protocol/proto/es_set_repeating_track.proto new file mode 100644 index 00000000..dd0588be --- /dev/null +++ b/protocol/proto/es_set_repeating_track.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetRepeatingTrackRequest { + bool repeating_track = 1; + CommandOptions options = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_set_shuffling_context.proto b/protocol/proto/es_set_shuffling_context.proto new file mode 100644 index 00000000..9a1a470c --- /dev/null +++ b/protocol/proto/es_set_shuffling_context.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SetShufflingContextRequest { + bool shuffling_context = 1; + CommandOptions options = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_skip_next.proto b/protocol/proto/es_skip_next.proto new file mode 100644 index 00000000..2761b0b9 --- /dev/null +++ b/protocol/proto/es_skip_next.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SkipNextRequest { + CommandOptions options = 1; + LoggingParams logging_params = 2; + ContextTrack track = 3; +} diff --git a/protocol/proto/es_skip_prev.proto b/protocol/proto/es_skip_prev.proto new file mode 100644 index 00000000..da354be2 --- /dev/null +++ b/protocol/proto/es_skip_prev.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; +import "es_context_track.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SkipPrevRequest { + CommandOptions options = 1; + bool allow_seeking = 2; + LoggingParams logging_params = 3; + ContextTrack track = 4; +} diff --git a/protocol/proto/es_skip_to_track.proto b/protocol/proto/es_skip_to_track.proto new file mode 100644 index 00000000..ba588811 --- /dev/null +++ b/protocol/proto/es_skip_to_track.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_optional.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message SkipToTrack { + string page_url = 1; + OptionalInt64 page_index = 2; + string track_uid = 3; + string track_uri = 4; + OptionalInt64 track_index = 5; +} diff --git a/protocol/proto/es_stop.proto b/protocol/proto/es_stop.proto new file mode 100644 index 00000000..cb3760fc --- /dev/null +++ b/protocol/proto/es_stop.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_command_options.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message StopRequest { + CommandOptions options = 1; + + StopRequest.Reason reason = 2; + enum Reason { + INTERACTIVE = 0; + REMOTE_TRANSFER = 1; + SHUTDOWN = 2; + } + + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/es_storage.proto b/protocol/proto/es_storage.proto new file mode 100644 index 00000000..c7d35f71 --- /dev/null +++ b/protocol/proto/es_storage.proto @@ -0,0 +1,84 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.storage.esperanto.proto; + +import "google/protobuf/empty.proto"; + +option java_package = "com.spotify.storage.esperanto.proto"; +option objc_class_prefix = "ESP"; + +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 { + message Range { + uint64 from_byte = 1; + uint64 to_byte = 2; + } + + bool byte_size_known = 1; + uint64 byte_size = 2; + repeated Range ranges = 3; +} + +message GetFileRangesParams { + Realm realm = 1; + string file_id = 2; +} + +enum Realm { + STREAM = 0; + COVER_ART = 1; + PLAYLIST = 4; + AUDIO_SHOW = 5; + HEAD_FILES = 7; + EXTERNAL_AUDIO_SHOW = 8; + KARAOKE_MASK = 9; +} diff --git a/protocol/proto/es_update.proto b/protocol/proto/es_update.proto new file mode 100644 index 00000000..498a0d26 --- /dev/null +++ b/protocol/proto/es_update.proto @@ -0,0 +1,38 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.player.esperanto.proto; + +import "es_context.proto"; +import "es_context_page.proto"; +import "es_context_track.proto"; +import "es_logging_params.proto"; + +option objc_class_prefix = "ESP"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.player.esperanto.proto"; + +message UpdateContextRequest { + string session_id = 1; + Context context = 2; + LoggingParams logging_params = 3; +} + +message UpdateContextPageRequest { + string session_id = 1; + ContextPage context_page = 2; + LoggingParams logging_params = 3; +} + +message UpdateContextTrackRequest { + string session_id = 1; + ContextTrack context_track = 2; + LoggingParams logging_params = 3; +} + +message UpdateViewUriRequest { + string session_id = 1; + string view_uri = 2; + LoggingParams logging_params = 3; +} diff --git a/protocol/proto/esperanto_options.proto b/protocol/proto/esperanto_options.proto new file mode 100644 index 00000000..5c914f06 --- /dev/null +++ b/protocol/proto/esperanto_options.proto @@ -0,0 +1,6 @@ +syntax = "proto3"; + +package spotify.esperanto; + +import "google/protobuf/descriptor.proto"; + diff --git a/protocol/proto/event_entity.proto b/protocol/proto/event_entity.proto new file mode 100644 index 00000000..b926ba6b --- /dev/null +++ b/protocol/proto/event_entity.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventEntity { + uint32 file_format_version = 1; + string event_name = 2; + bytes sequence_id = 3; + uint64 sequence_number = 4; + bytes payload = 5; + string owner = 6; + bool authenticated = 7; + uint64 record_id = 8; +} diff --git a/protocol/proto/explicit_content_pubsub.proto b/protocol/proto/explicit_content_pubsub.proto new file mode 100644 index 00000000..14679488 --- /dev/null +++ b/protocol/proto/explicit_content_pubsub.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.explicit_content.proto; + +option optimize_for = CODE_SIZE; + +message UserAttributesUpdate { + map pairs = 1; +} diff --git a/protocol/proto/extended_metadata.proto b/protocol/proto/extended_metadata.proto new file mode 100644 index 00000000..b48b34eb --- /dev/null +++ b/protocol/proto/extended_metadata.proto @@ -0,0 +1,61 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.extendedmetadata; + +import "extension_kind.proto"; +import "entity_extension_data.proto"; + +option cc_enable_arenas = true; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.extendedmetadata.proto"; + +message ExtensionQuery { + ExtensionKind extension_kind = 1; + string etag = 2; +} + +message EntityRequest { + string entity_uri = 1; + repeated ExtensionQuery query = 2; +} + +message BatchedEntityRequestHeader { + string country = 1; + string catalogue = 2; + bytes task_id = 3; +} + +message BatchedEntityRequest { + BatchedEntityRequestHeader header = 1; + repeated EntityRequest entity_request = 2; +} + +message EntityExtensionDataArrayHeader { + int32 provider_error_status = 1; + int64 cache_ttl_in_seconds = 2; + int64 offline_ttl_in_seconds = 3; + ExtensionType extension_type = 4; +} + +message EntityExtensionDataArray { + EntityExtensionDataArrayHeader header = 1; + ExtensionKind extension_kind = 2; + repeated EntityExtensionData extension_data = 3; +} + +message BatchedExtensionResponseHeader { +} + +message BatchedExtensionResponse { + BatchedExtensionResponseHeader header = 1; + repeated EntityExtensionDataArray extended_metadata = 2; +} + +enum ExtensionType { + UNKNOWN = 0; + GENERIC = 1; + ASSOC = 2; +} diff --git a/protocol/proto/extension_descriptor_type.proto b/protocol/proto/extension_descriptor_type.proto new file mode 100644 index 00000000..2ca05713 --- /dev/null +++ b/protocol/proto/extension_descriptor_type.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.descriptorextension; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.descriptorextension.proto"; + +message ExtensionDescriptor { + string text = 1; + float weight = 2; + repeated ExtensionDescriptorType types = 3; +} + +message ExtensionDescriptorData { + repeated ExtensionDescriptor descriptors = 1; +} + +enum ExtensionDescriptorType { + UNKNOWN = 0; + GENRE = 1; + MOOD = 2; + ACTIVITY = 3; + INSTRUMENT = 4; + TIME = 5; + ERA = 6; + AESTHETIC = 7; +} diff --git a/protocol/proto/extension_kind.proto b/protocol/proto/extension_kind.proto new file mode 100644 index 00000000..6bb8182b --- /dev/null +++ b/protocol/proto/extension_kind.proto @@ -0,0 +1,210 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.extendedmetadata; + +option objc_class_prefix = "SPTExtendedMetadata"; +option cc_enable_arenas = true; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.extendedmetadata.proto"; + +enum ExtensionKind { + UNKNOWN_EXTENSION = 0; + CANVAZ = 1; + STORYLINES = 2; + PODCAST_TOPICS = 3; + PODCAST_SEGMENTS = 4; + AUDIO_FILES = 5; + TRACK_DESCRIPTOR = 6; + PODCAST_COUNTER = 7; + ARTIST_V4 = 8; + ALBUM_V4 = 9; + TRACK_V4 = 10; + SHOW_V4 = 11; + EPISODE_V4 = 12; + PODCAST_HTML_DESCRIPTION = 13; + PODCAST_QUOTES = 14; + USER_PROFILE = 15; + CANVAS_V1 = 16; + SHOW_V4_BASE = 17; + SHOW_V4_EPISODES_ASSOC = 18; + TRACK_DESCRIPTOR_SIGNATURES = 19; + PODCAST_AD_SEGMENTS = 20; + EPISODE_TRANSCRIPTS = 21; + PODCAST_SUBSCRIPTIONS = 22; + EXTRACTED_COLOR = 23; + PODCAST_VIRALITY = 24; + IMAGE_SPARKLES_HACK = 25; + PODCAST_POPULARITY_HACK = 26; + AUTOMIX_MODE = 27; + CUEPOINTS = 28; + PODCAST_POLL = 29; + EPISODE_ACCESS = 30; + SHOW_ACCESS = 31; + PODCAST_QNA = 32; + CLIPS = 33; + SHOW_V5 = 34; + EPISODE_V5 = 35; + PODCAST_CTA_CARDS = 36; + PODCAST_RATING = 37; + DISPLAY_SEGMENTS = 38; + GREENROOM = 39; + USER_CREATED = 40; + SHOW_DESCRIPTION = 41; + SHOW_HTML_DESCRIPTION = 42; + SHOW_PLAYABILITY = 43; + EPISODE_DESCRIPTION = 44; + EPISODE_HTML_DESCRIPTION = 45; + EPISODE_PLAYABILITY = 46; + SHOW_EPISODES_ASSOC = 47; + CLIENT_CONFIG = 48; + PLAYLISTABILITY = 49; + AUDIOBOOK_V5 = 50; + CHAPTER_V5 = 51; + AUDIOBOOK_SPECIFICS = 52; + EPISODE_RANKING = 53; + HTML_DESCRIPTION = 54; + CREATOR_CHANNEL = 55; + AUDIOBOOK_PROVIDERS = 56; + PLAY_TRAIT = 57; + CONTENT_WARNING = 58; + IMAGE_CUE = 59; + STREAM_COUNT = 60; + AUDIO_ATTRIBUTES = 61; + NAVIGABLE_TRAIT = 62; + NEXT_BEST_EPISODE = 63; + AUDIOBOOK_PRICE = 64; + EXPRESSIVE_PLAYLISTS = 65; + DYNAMIC_SHOW_EPISODE = 66; + LIVE = 67; + SKIP_PLAYED = 68; + AD_BREAK_FREE_PODCASTS = 69; + ASSOCIATIONS = 70; + PLAYLIST_EVALUATION = 71; + CACHE_INVALIDATIONS = 72; + LIVESTREAM_ENTITY = 73; + SINGLE_TAP_REACTIONS = 74; + USER_COMMENTS = 75; + CLIENT_RESTRICTIONS = 76; + PODCAST_GUEST = 77; + PLAYABILITY = 78; + COVER_IMAGE = 79; + SHARE_TRAIT = 80; + INSTANCE_SHARING = 81; + ARTIST_TOUR = 82; + AUDIOBOOK_GENRE = 83; + CONCEPT = 84; + ORIGINAL_VIDEO = 85; + SMART_SHUFFLE = 86; + LIVE_EVENTS = 87; + AUDIOBOOK_RELATIONS = 88; + HOME_POC_BASECARD = 89; + AUDIOBOOK_SUPPLEMENTS = 90; + PAID_PODCAST_BANNER = 91; + FEWER_ADS = 92; + WATCH_FEED_SHOW_EXPLORER = 93; + TRACK_EXTRA_DESCRIPTORS = 94; + TRACK_EXTRA_AUDIO_ATTRIBUTES = 95; + TRACK_EXTENDED_CREDITS = 96; + SIMPLE_TRAIT = 97; + AUDIO_ASSOCIATIONS = 98; + VIDEO_ASSOCIATIONS = 99; + PLAYLIST_TUNER = 100; + ARTIST_VIDEOS_ENTRYPOINT = 101; + ALBUM_PRERELEASE = 102; + CONTENT_ALTERNATIVES = 103; + SNAPSHOT_SHARING = 105; + DISPLAY_SEGMENTS_COUNT = 106; + PODCAST_FEATURED_EPISODE = 107; + PODCAST_SPONSORED_CONTENT = 108; + PODCAST_EPISODE_TOPICS_LLM = 109; + PODCAST_EPISODE_TOPICS_KG = 110; + EPISODE_RANKING_POPULARITY = 111; + MERCH = 112; + COMPANION_CONTENT = 113; + WATCH_FEED_ENTITY_EXPLORER = 114; + ANCHOR_CARD_TRAIT = 115; + AUDIO_PREVIEW_PLAYBACK_TRAIT = 116; + VIDEO_PREVIEW_STILL_TRAIT = 117; + PREVIEW_CARD_TRAIT = 118; + SHORTCUTS_CARD_TRAIT = 119; + VIDEO_PREVIEW_PLAYBACK_TRAIT = 120; + COURSE_SPECIFICS = 121; + CONCERT = 122; + CONCERT_LOCATION = 123; + CONCERT_MARKETING = 124; + CONCERT_PERFORMERS = 125; + TRACK_PAIR_TRANSITION = 126; + CONTENT_TYPE_TRAIT = 127; + NAME_TRAIT = 128; + ARTWORK_TRAIT = 129; + RELEASE_DATE_TRAIT = 130; + CREDITS_TRAIT = 131; + RELEASE_URI_TRAIT = 132; + ENTITY_CAPPING = 133; + LESSON_SPECIFICS = 134; + CONCERT_OFFERS = 135; + TRANSITION_MAPS = 136; + ARTIST_HAS_CONCERTS = 137; + PRERELEASE = 138; + PLAYLIST_ATTRIBUTES_V2 = 139; + LIST_ATTRIBUTES_V2 = 140; + LIST_METADATA = 141; + LIST_TUNER_AUDIO_ANALYSIS = 142; + LIST_TUNER_CUEPOINTS = 143; + CONTENT_RATING_TRAIT = 144; + COPYRIGHT_TRAIT = 145; + SUPPORTED_BADGES = 146; + BADGES = 147; + PREVIEW_TRAIT = 148; + ROOTLISTABILITY_TRAIT = 149; + LOCAL_CONCERTS = 150; + RECOMMENDED_PLAYLISTS = 151; + POPULAR_RELEASES = 152; + RELATED_RELEASES = 153; + SHARE_RESTRICTIONS = 154; + CONCERT_OFFER = 155; + CONCERT_OFFER_PROVIDER = 156; + ENTITY_BOOKMARKS = 157; + PRIVACY_TRAIT = 158; + DUPLICATE_ITEMS_TRAIT = 159; + REORDERING_TRAIT = 160; + PODCAST_RESUMPTION_SEGMENTS = 161; + ARTIST_EXPRESSION_VIDEO = 162; + PRERELEASE_VIDEO = 163; + GATED_ENTITY_RELATIONS = 164; + RELATED_CREATORS_SECTION = 165; + CREATORS_APPEARS_ON_SECTION = 166; + PROMO_V1_TRAIT = 167; + SPEECHLESS_SHARE_CARD = 168; + TOP_PLAYABLES_SECTION = 169; + AUTO_LENS = 170; + PROMO_V3_TRAIT = 171; + TRACK_CONTENT_FILTER = 172; + HIGHLIGHTABILITY = 173; + LINK_CARD_WITH_IMAGE_TRAIT = 174; + TRACK_CLOUD_SECTION = 175; + EPISODE_TOPICS = 176; + VIDEO_THUMBNAIL = 177; + IDENTITY_TRAIT = 178; + VISUAL_IDENTITY_TRAIT = 179; + CONTENT_TYPE_V2_TRAIT = 180; + PREVIEW_PLAYBACK_TRAIT = 181; + CONSUMPTION_EXPERIENCE_TRAIT = 182; + PUBLISHING_METADATA_TRAIT = 183; + DETAILED_EVALUATION_TRAIT = 184; + ON_PLATFORM_REPUTATION_TRAIT = 185; + CREDITS_V2_TRAIT = 186; + HIGHLIGHT_PLAYABILITY_TRAIT = 187; + SHOW_EPISODE_LIST = 188; + AVAILABLE_RELEASES = 189; + PLAYLIST_DESCRIPTORS = 190; + LINK_CARD_WITH_ANIMATIONS_TRAIT = 191; + RECAP = 192; + AUDIOBOOK_COMPANION_CONTENT = 193; + THREE_OH_THREE_PLAY_TRAIT = 194; + ARTIST_WRAPPED_2024_VIDEO = 195; +} + diff --git a/protocol/proto/extracted_colors.proto b/protocol/proto/extracted_colors.proto new file mode 100644 index 00000000..cf8b8ca5 --- /dev/null +++ b/protocol/proto/extracted_colors.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.context_track_color; + +message ColorResult { + Color color_raw = 1; + Color color_light = 2; + Color color_dark = 3; + Status status = 5; +} + +message Color { + int32 rgb = 1; + bool is_fallback = 2; +} + +enum Status { + OK = 0; + IN_PROGRESS = 1; + INVALID_URL = 2; + INTERNAL = 3; +} diff --git a/protocol/proto/facebook-publish.proto b/protocol/proto/facebook-publish.proto deleted file mode 100644 index 4edef249..00000000 --- a/protocol/proto/facebook-publish.proto +++ /dev/null @@ -1,51 +0,0 @@ -syntax = "proto2"; - -message EventReply { - optional int32 queued = 0x1; - optional RetryInfo retry = 0x2; -} - -message RetryInfo { - optional int32 retry_delay = 0x1; - optional int32 max_retry = 0x2; -} - -message Id { - optional string uri = 0x1; - optional int64 start_time = 0x2; -} - -message Start { - optional int32 length = 0x1; - optional string context_uri = 0x2; - optional int64 end_time = 0x3; -} - -message Seek { - optional int64 end_time = 0x1; -} - -message Pause { - optional int32 seconds_played = 0x1; - optional int64 end_time = 0x2; -} - -message Resume { - optional int32 seconds_played = 0x1; - optional int64 end_time = 0x2; -} - -message End { - optional int32 seconds_played = 0x1; - optional int64 end_time = 0x2; -} - -message Event { - optional Id id = 0x1; - optional Start start = 0x2; - optional Seek seek = 0x3; - optional Pause pause = 0x4; - optional Resume resume = 0x5; - optional End end = 0x6; -} - diff --git a/protocol/proto/facebook.proto b/protocol/proto/facebook.proto deleted file mode 100644 index 8227c5a1..00000000 --- a/protocol/proto/facebook.proto +++ /dev/null @@ -1,183 +0,0 @@ -syntax = "proto2"; - -message Credential { - optional string facebook_uid = 0x1; - optional string access_token = 0x2; -} - -message EnableRequest { - optional Credential credential = 0x1; -} - -message EnableReply { - optional Credential credential = 0x1; -} - -message DisableRequest { - optional Credential credential = 0x1; -} - -message RevokeRequest { - optional Credential credential = 0x1; -} - -message InspectCredentialRequest { - optional Credential credential = 0x1; -} - -message InspectCredentialReply { - optional Credential alternative_credential = 0x1; - optional bool app_user = 0x2; - optional bool permanent_error = 0x3; - optional bool transient_error = 0x4; -} - -message UserState { - optional Credential credential = 0x1; -} - -message UpdateUserStateRequest { - optional Credential credential = 0x1; -} - -message OpenGraphError { - repeated string permanent = 0x1; - repeated string invalid_token = 0x2; - repeated string retries = 0x3; -} - -message OpenGraphScrobble { - optional int32 create_delay = 0x1; -} - -message OpenGraphConfig { - optional OpenGraphError error = 0x1; - optional OpenGraphScrobble scrobble = 0x2; -} - -message AuthConfig { - optional string url = 0x1; - repeated string permissions = 0x2; - repeated string blacklist = 0x3; - repeated string whitelist = 0x4; - repeated string cancel = 0x5; -} - -message ConfigReply { - optional string domain = 0x1; - optional string app_id = 0x2; - optional string app_namespace = 0x3; - optional AuthConfig auth = 0x4; - optional OpenGraphConfig og = 0x5; -} - -message UserFields { - optional bool app_user = 0x1; - optional bool display_name = 0x2; - optional bool first_name = 0x3; - optional bool middle_name = 0x4; - optional bool last_name = 0x5; - optional bool picture_large = 0x6; - optional bool picture_square = 0x7; - optional bool gender = 0x8; - optional bool email = 0x9; -} - -message UserOptions { - optional bool cache_is_king = 0x1; -} - -message UserRequest { - optional UserOptions options = 0x1; - optional UserFields fields = 0x2; -} - -message User { - optional string spotify_username = 0x1; - optional string facebook_uid = 0x2; - optional bool app_user = 0x3; - optional string display_name = 0x4; - optional string first_name = 0x5; - optional string middle_name = 0x6; - optional string last_name = 0x7; - optional string picture_large = 0x8; - optional string picture_square = 0x9; - optional string gender = 0xa; - optional string email = 0xb; -} - -message FriendsFields { - optional bool app_user = 0x1; - optional bool display_name = 0x2; - optional bool picture_large = 0x6; -} - -message FriendsOptions { - optional int32 limit = 0x1; - optional int32 offset = 0x2; - optional bool cache_is_king = 0x3; - optional bool app_friends = 0x4; - optional bool non_app_friends = 0x5; -} - -message FriendsRequest { - optional FriendsOptions options = 0x1; - optional FriendsFields fields = 0x2; -} - -message FriendsReply { - repeated User friends = 0x1; - optional bool more = 0x2; -} - -message ShareRequest { - optional Credential credential = 0x1; - optional string uri = 0x2; - optional string message_text = 0x3; -} - -message ShareReply { - optional string post_id = 0x1; -} - -message InboxRequest { - optional Credential credential = 0x1; - repeated string facebook_uids = 0x3; - optional string message_text = 0x4; - optional string message_link = 0x5; -} - -message InboxReply { - optional string message_id = 0x1; - optional string thread_id = 0x2; -} - -message PermissionsOptions { - optional bool cache_is_king = 0x1; -} - -message PermissionsRequest { - optional Credential credential = 0x1; - optional PermissionsOptions options = 0x2; -} - -message PermissionsReply { - repeated string permissions = 0x1; -} - -message GrantPermissionsRequest { - optional Credential credential = 0x1; - repeated string permissions = 0x2; -} - -message GrantPermissionsReply { - repeated string granted = 0x1; - repeated string failed = 0x2; -} - -message TransferRequest { - optional Credential credential = 0x1; - optional string source_username = 0x2; - optional string target_username = 0x3; -} - diff --git a/protocol/proto/follow_request.proto b/protocol/proto/follow_request.proto new file mode 100644 index 00000000..913573ee --- /dev/null +++ b/protocol/proto/follow_request.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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 FollowRequestV4 { + 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..a0d2dfc0 --- /dev/null +++ b/protocol/proto/followed_users_request.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.socialgraph_esperanto.proto; + +import "socialgraph_response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.socialgraph.esperanto.proto"; + +message FollowedUsersRequest { + bool force_reload = 1; +} + +message FollowedUsersResponse { + ResponseStatus status = 1; + repeated string users = 2; +} diff --git a/protocol/proto/frecency.proto b/protocol/proto/frecency.proto new file mode 100644 index 00000000..3f875aa1 --- /dev/null +++ b/protocol/proto/frecency.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.frecency.v1; + +import "google/protobuf/timestamp.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "FrecencyProto"; +option java_package = "com.spotify.frecency.v1"; + +message FrecencyResponse { + repeated PlayContext play_contexts = 1; +} + +message PlayContext { + string uri = 1; + Frecency frecency = 2; +} + +message Frecency { + double ln_frecency = 1; + int32 event_count = 2; + google.protobuf.Timestamp last_event_time = 3; +} diff --git a/protocol/proto/frecency_storage.proto b/protocol/proto/frecency_storage.proto new file mode 100644 index 00000000..f1b50487 --- /dev/null +++ b/protocol/proto/frecency_storage.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.frecency.proto.storage; + +option cc_enable_arenas = true; +option optimize_for = CODE_SIZE; + +message Frecency { + optional double ln_frecency = 1; + optional uint64 event_count = 2; + optional uint32 event_kind = 3; + optional uint64 last_event_time = 4; +} + +message ContextFrecencyInfo { + optional string context_uri = 1; + repeated Frecency context_frecencies = 2; +} + +message ContextFrecencyFile { + repeated ContextFrecencyInfo contexts = 1; + optional uint64 frecency_version = 2; +} diff --git a/protocol/proto/gabito.proto b/protocol/proto/gabito.proto new file mode 100644 index 00000000..3256727e --- /dev/null +++ b/protocol/proto/gabito.proto @@ -0,0 +1,37 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message EventEnvelope { + string event_name = 2; + + repeated EventFragment event_fragment = 3; + message EventFragment { + string name = 1; + bytes data = 2; + } + + bytes sequence_id = 4; + int64 sequence_number = 5; + + reserved 1; +} + +message PublishEventsRequest { + repeated EventEnvelope event = 1; + bool suppress_persist = 2; +} + +message PublishEventsResponse { + message EventError { + int32 index = 1; + bool transient = 2; + int32 reason = 3; + } + + repeated EventError error = 1; +} diff --git a/protocol/proto/global_node.proto b/protocol/proto/global_node.proto new file mode 100644 index 00000000..f54604a5 --- /dev/null +++ b/protocol/proto/global_node.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_player_options.proto"; +import "pause_resume_origin.proto"; +import "player_license.proto"; + +option optimize_for = CODE_SIZE; + +message GlobalNode { + optional ContextPlayerOptions options = 1; + optional PlayerLicense license = 2; + map configuration = 3; + optional PauseResumeOrigin pause_resume_origin = 4; + optional bool is_paused = 5; +} diff --git a/protocol/proto/google/protobuf/any.proto b/protocol/proto/google/protobuf/any.proto new file mode 100644 index 00000000..7a7e77fb --- /dev/null +++ b/protocol/proto/google/protobuf/any.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option go_package = "google.golang.org/protobuf/types/known/anypb"; +option java_multiple_files = true; +option java_outer_classname = "AnyProto"; +option java_package = "com.google.protobuf"; + +message Any { + string type_url = 1; + bytes value = 2; +} diff --git a/protocol/proto/google/protobuf/descriptor.proto b/protocol/proto/google/protobuf/descriptor.proto new file mode 100644 index 00000000..49ccd18b --- /dev/null +++ b/protocol/proto/google/protobuf/descriptor.proto @@ -0,0 +1,423 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.Reflection"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/descriptorpb"; +option optimize_for = SPEED; +option java_outer_classname = "DescriptorProtos"; +option java_package = "com.google.protobuf"; + +message FileDescriptorSet { + repeated FileDescriptorProto file = 1; +} + +message FileDescriptorProto { + optional string name = 1; + optional string package = 2; + repeated string dependency = 3; + repeated int32 public_dependency = 10; + repeated int32 weak_dependency = 11; + repeated DescriptorProto message_type = 4; + repeated EnumDescriptorProto enum_type = 5; + repeated ServiceDescriptorProto service = 6; + repeated FieldDescriptorProto extension = 7; + optional FileOptions options = 8; + optional SourceCodeInfo source_code_info = 9; + optional string syntax = 12; + optional Edition edition = 14; +} + +message DescriptorProto { + optional string name = 1; + repeated FieldDescriptorProto field = 2; + repeated FieldDescriptorProto extension = 6; + repeated DescriptorProto nested_type = 3; + repeated EnumDescriptorProto enum_type = 4; + + repeated ExtensionRange extension_range = 5; + message ExtensionRange { + optional int32 start = 1; + optional int32 end = 2; + optional ExtensionRangeOptions options = 3; + } + + repeated OneofDescriptorProto oneof_decl = 8; + optional MessageOptions options = 7; + + repeated ReservedRange reserved_range = 9; + message ReservedRange { + optional int32 start = 1; + optional int32 end = 2; + } + + repeated string reserved_name = 10; +} + +message ExtensionRangeOptions { + message Declaration { + reserved 4; + optional int32 number = 1; + optional string full_name = 2; + optional string type = 3; + optional bool reserved = 5; + optional bool repeated = 6; + } + + enum VerificationState { + DECLARATION = 0; + UNVERIFIED = 1; + } + + repeated UninterpretedOption uninterpreted_option = 999; + repeated Declaration declaration = 2; + optional FeatureSet features = 50; + optional VerificationState verification = 3 [default = UNVERIFIED]; +} + +message FieldDescriptorProto { + optional string name = 1; + optional int32 number = 3; + + optional Label label = 4; + enum Label { + LABEL_OPTIONAL = 1; + LABEL_REPEATED = 3; + LABEL_REQUIRED = 2; + } + + optional Type type = 5; + enum Type { + TYPE_DOUBLE = 1; + TYPE_FLOAT = 2; + TYPE_INT64 = 3; + TYPE_UINT64 = 4; + TYPE_INT32 = 5; + TYPE_FIXED64 = 6; + TYPE_FIXED32 = 7; + TYPE_BOOL = 8; + TYPE_STRING = 9; + TYPE_GROUP = 10; + TYPE_MESSAGE = 11; + TYPE_BYTES = 12; + TYPE_UINT32 = 13; + TYPE_ENUM = 14; + TYPE_SFIXED32 = 15; + TYPE_SFIXED64 = 16; + TYPE_SINT32 = 17; + TYPE_SINT64 = 18; + } + + optional string type_name = 6; + optional string extendee = 2; + optional string default_value = 7; + optional int32 oneof_index = 9; + optional string json_name = 10; + optional FieldOptions options = 8; + optional bool proto3_optional = 17; +} + +message OneofDescriptorProto { + optional string name = 1; + optional OneofOptions options = 2; +} + +message EnumDescriptorProto { + optional string name = 1; + repeated EnumValueDescriptorProto value = 2; + optional EnumOptions options = 3; + + repeated EnumReservedRange reserved_range = 4; + message EnumReservedRange { + optional int32 start = 1; + optional int32 end = 2; + } + + repeated string reserved_name = 5; +} + +message EnumValueDescriptorProto { + optional string name = 1; + optional int32 number = 2; + optional EnumValueOptions options = 3; +} + +message ServiceDescriptorProto { + optional string name = 1; + repeated MethodDescriptorProto method = 2; + optional ServiceOptions options = 3; +} + +message MethodDescriptorProto { + optional string name = 1; + optional string input_type = 2; + optional string output_type = 3; + optional MethodOptions options = 4; + optional bool client_streaming = 5 [default = false]; + optional bool server_streaming = 6 [default = false]; +} + +message FileOptions { + optional string java_package = 1; + optional string java_outer_classname = 8; + optional bool java_multiple_files = 10 [default = false]; + optional bool java_generate_equals_and_hash = 20; + optional bool java_string_check_utf8 = 27 [default = false]; + + optional OptimizeMode optimize_for = 9 [default = SPEED]; + enum OptimizeMode { + SPEED = 1; + CODE_SIZE = 2; + LITE_RUNTIME = 3; + } + + optional string go_package = 11; + optional bool cc_generic_services = 16 [default = false]; + optional bool java_generic_services = 17 [default = false]; + optional bool py_generic_services = 18 [default = false]; + optional bool php_generic_services = 42 [default = false]; + optional bool deprecated = 23 [default = false]; + optional bool cc_enable_arenas = 31 [default = true]; + optional string objc_class_prefix = 36; + optional string csharp_namespace = 37; + optional string swift_prefix = 39; + optional string php_class_prefix = 40; + optional string php_namespace = 41; + optional string php_metadata_namespace = 44; + optional string ruby_package = 45; + optional FeatureSet features = 50; + repeated UninterpretedOption uninterpreted_option = 999; + + reserved 38; +} + +message MessageOptions { + optional bool message_set_wire_format = 1 [default = false]; + optional bool no_standard_descriptor_accessor = 2 [default = false]; + optional bool deprecated = 3 [default = false]; + optional bool map_entry = 7; + optional bool deprecated_legacy_json_field_conflicts = 11; + optional FeatureSet features = 12; + repeated UninterpretedOption uninterpreted_option = 999; + + reserved 4, 5, 6, 8, 9; +} + +message FieldOptions { + optional CType ctype = 1 [default = STRING]; + enum CType { + STRING = 0; + CORD = 1; + STRING_PIECE = 2; + } + + optional bool packed = 2; + + optional JSType jstype = 6 [default = JS_NORMAL]; + enum JSType { + JS_NORMAL = 0; + JS_STRING = 1; + JS_NUMBER = 2; + } + + optional bool lazy = 5 [default = false]; + optional bool unverified_lazy = 15 [default = false]; + optional bool deprecated = 3 [default = false]; + optional bool weak = 10 [default = false]; + optional bool debug_redact = 16 [default = false]; + + optional OptionRetention retention = 17; + enum OptionRetention { + RETENTION_UNKNOWN = 0; + RETENTION_RUNTIME = 1; + RETENTION_SOURCE = 2; + } + + repeated OptionTargetType targets = 19; + enum OptionTargetType { + TARGET_TYPE_UNKNOWN = 0; + TARGET_TYPE_FILE = 1; + TARGET_TYPE_EXTENSION_RANGE = 2; + TARGET_TYPE_MESSAGE = 3; + TARGET_TYPE_FIELD = 4; + TARGET_TYPE_ONEOF = 5; + TARGET_TYPE_ENUM = 6; + TARGET_TYPE_ENUM_ENTRY = 7; + TARGET_TYPE_SERVICE = 8; + TARGET_TYPE_METHOD = 9; + } + + + repeated EditionDefault edition_defaults = 20; + message EditionDefault { + optional Edition edition = 3; + optional string value = 2; + } + + optional FeatureSet features = 21; + repeated UninterpretedOption uninterpreted_option = 999; + + reserved 4, 18; +} + +message OneofOptions { + optional FeatureSet features = 1; + repeated UninterpretedOption uninterpreted_option = 999; +} + +message EnumOptions { + optional bool allow_alias = 2; + optional bool deprecated = 3 [default = false]; + optional bool deprecated_legacy_json_field_conflicts = 6; + optional FeatureSet features = 7; + repeated UninterpretedOption uninterpreted_option = 999; + + reserved 5; +} + +message EnumValueOptions { + optional bool deprecated = 1 [default = false]; + optional FeatureSet features = 2; + optional bool debug_redact = 3 [default = false]; + repeated UninterpretedOption uninterpreted_option = 999; +} + +message ServiceOptions { + optional FeatureSet features = 34; + optional bool deprecated = 33 [default = false]; + repeated UninterpretedOption uninterpreted_option = 999; +} + +message MethodOptions { + optional bool deprecated = 33 [default = false]; + + optional IdempotencyLevel idempotency_level = 34 [default = IDEMPOTENCY_UNKNOWN]; + enum IdempotencyLevel { + IDEMPOTENCY_UNKNOWN = 0; + NO_SIDE_EFFECTS = 1; + IDEMPOTENT = 2; + } + + optional FeatureSet features = 35; + repeated UninterpretedOption uninterpreted_option = 999; +} + +message UninterpretedOption { + message NamePart { + required string name_part = 1; + required bool is_extension = 2; + } + + repeated UninterpretedOption.NamePart name = 2; + optional string identifier_value = 3; + optional uint64 positive_int_value = 4; + optional int64 negative_int_value = 5; + optional double double_value = 6; + optional bytes string_value = 7; + optional string aggregate_value = 8; +} + +message FeatureSet { + reserved 999; + enum FieldPresence { + FIELD_PRESENCE_UNKNOWN = 0; + EXPLICIT = 1; + IMPLICIT = 2; + LEGACY_REQUIRED = 3; + } + + enum EnumType { + ENUM_TYPE_UNKNOWN = 0; + OPEN = 1; + CLOSED = 2; + } + + enum RepeatedFieldEncoding { + REPEATED_FIELD_ENCODING_UNKNOWN = 0; + PACKED = 1; + EXPANDED = 2; + } + + enum Utf8Validation { + UTF8_VALIDATION_UNKNOWN = 0; + NONE = 1; + VERIFY = 2; + } + + enum MessageEncoding { + MESSAGE_ENCODING_UNKNOWN = 0; + LENGTH_PREFIXED = 1; + DELIMITED = 2; + } + + enum JsonFormat { + JSON_FORMAT_UNKNOWN = 0; + ALLOW = 1; + LEGACY_BEST_EFFORT = 2; + } + + optional FieldPresence field_presence = 1; + optional EnumType enum_type = 2; + optional RepeatedFieldEncoding repeated_field_encoding = 3; + optional Utf8Validation utf8_validation = 4; + optional MessageEncoding message_encoding = 5; + optional JsonFormat json_format = 6; +} + +message FeatureSetDefaults { + message FeatureSetEditionDefault { + optional Edition edition = 3; + optional FeatureSet features = 2; + } + + repeated FeatureSetDefaults.FeatureSetEditionDefault defaults = 1; + optional Edition minimum_edition = 4; + optional Edition maximum_edition = 5; +} + +message SourceCodeInfo { + message Location { + repeated int32 path = 1; + repeated int32 span = 2; + optional string leading_comments = 3; + optional string trailing_comments = 4; + repeated string leading_detached_comments = 6; + } + + repeated Location location = 1; +} + +message GeneratedCodeInfo { + message Annotation { + enum Semantic { + NONE = 0; + SET = 1; + ALIAS = 2; + } + + repeated int32 path = 1; + optional string source_file = 2; + optional int32 begin = 3; + optional int32 end = 4; + optional Annotation.Semantic semantic = 5; + } + + repeated Annotation annotation = 1; +} + +enum Edition { + EDITION_UNKNOWN = 0; + EDITION_PROTO2 = 998; + EDITION_PROTO3 = 999; + EDITION_2023 = 1000; + EDITION_1_TEST_ONLY = 1; + EDITION_2_TEST_ONLY = 2; + EDITION_99997_TEST_ONLY = 99997; + EDITION_99998_TEST_ONLY = 99998; + EDITION_99999_TEST_ONLY = 99999; +} + diff --git a/protocol/proto/google/protobuf/duration.proto b/protocol/proto/google/protobuf/duration.proto new file mode 100644 index 00000000..dc76d4eb --- /dev/null +++ b/protocol/proto/google/protobuf/duration.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/durationpb"; +option java_multiple_files = true; +option java_outer_classname = "DurationProto"; +option java_package = "com.google.protobuf"; + +message Duration { + int64 seconds = 1; + int32 nanos = 2; +} diff --git a/protocol/proto/google/protobuf/empty.proto b/protocol/proto/google/protobuf/empty.proto new file mode 100644 index 00000000..25f74377 --- /dev/null +++ b/protocol/proto/google/protobuf/empty.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/emptypb"; +option java_multiple_files = true; +option java_outer_classname = "EmptyProto"; +option java_package = "com.google.protobuf"; + +message Empty { + +} diff --git a/protocol/proto/google/protobuf/field_mask.proto b/protocol/proto/google/protobuf/field_mask.proto new file mode 100644 index 00000000..860a8709 --- /dev/null +++ b/protocol/proto/google/protobuf/field_mask.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/fieldmaskpb"; +option java_multiple_files = true; +option java_outer_classname = "FieldMaskProto"; +option java_package = "com.google.protobuf"; + +message FieldMask { + repeated string paths = 1; +} diff --git a/protocol/proto/google/protobuf/source_context.proto b/protocol/proto/google/protobuf/source_context.proto new file mode 100644 index 00000000..e19c07cc --- /dev/null +++ b/protocol/proto/google/protobuf/source_context.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option go_package = "google.golang.org/protobuf/types/known/sourcecontextpb"; +option java_multiple_files = true; +option java_outer_classname = "SourceContextProto"; +option java_package = "com.google.protobuf"; + +message SourceContext { + string file_name = 1; +} diff --git a/protocol/proto/google/protobuf/timestamp.proto b/protocol/proto/google/protobuf/timestamp.proto new file mode 100644 index 00000000..084f311a --- /dev/null +++ b/protocol/proto/google/protobuf/timestamp.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/timestamppb"; +option java_multiple_files = true; +option java_outer_classname = "TimestampProto"; +option java_package = "com.google.protobuf"; + +message Timestamp { + int64 seconds = 1; + int32 nanos = 2; +} diff --git a/protocol/proto/google/protobuf/type.proto b/protocol/proto/google/protobuf/type.proto new file mode 100644 index 00000000..d7b79651 --- /dev/null +++ b/protocol/proto/google/protobuf/type.proto @@ -0,0 +1,94 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package google.protobuf; + +import "google/protobuf/any.proto"; +import "google/protobuf/source_context.proto"; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/typepb"; +option java_multiple_files = true; +option java_outer_classname = "TypeProto"; +option java_package = "com.google.protobuf"; + +message Type { + string name = 1; + repeated Field fields = 2; + repeated string oneofs = 3; + repeated Option options = 4; + SourceContext source_context = 5; + Syntax syntax = 6; + string edition = 7; +} + +message Field { + enum Kind { + TYPE_UNKNOWN = 0; + TYPE_DOUBLE = 1; + TYPE_FLOAT = 2; + TYPE_INT64 = 3; + TYPE_UINT64 = 4; + TYPE_INT32 = 5; + TYPE_FIXED64 = 6; + TYPE_FIXED32 = 7; + TYPE_BOOL = 8; + TYPE_STRING = 9; + TYPE_GROUP = 10; + TYPE_MESSAGE = 11; + TYPE_BYTES = 12; + TYPE_UINT32 = 13; + TYPE_ENUM = 14; + TYPE_SFIXED32 = 15; + TYPE_SFIXED64 = 16; + TYPE_SINT32 = 17; + TYPE_SINT64 = 18; + } + + enum Cardinality { + CARDINALITY_UNKNOWN = 0; + CARDINALITY_OPTIONAL = 1; + CARDINALITY_REQUIRED = 2; + CARDINALITY_REPEATED = 3; + } + + Kind kind = 1; + Cardinality cardinality = 2; + int32 number = 3; + string name = 4; + string type_url = 6; + int32 oneof_index = 7; + bool packed = 8; + repeated Option options = 9; + string json_name = 10; + string default_value = 11; +} + +message Enum { + string name = 1; + repeated EnumValue enumvalue = 2; + repeated Option options = 3; + SourceContext source_context = 4; + Syntax syntax = 5; + string edition = 6; +} + +message EnumValue { + string name = 1; + int32 number = 2; + repeated Option options = 3; +} + +message Option { + string name = 1; + Any value = 2; +} + +enum Syntax { + SYNTAX_PROTO2 = 0; + SYNTAX_PROTO3 = 1; + SYNTAX_EDITIONS = 2; +} diff --git a/protocol/proto/google/protobuf/wrappers.proto b/protocol/proto/google/protobuf/wrappers.proto new file mode 100644 index 00000000..647641a3 --- /dev/null +++ b/protocol/proto/google/protobuf/wrappers.proto @@ -0,0 +1,49 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/wrapperspb"; +option java_multiple_files = true; +option java_outer_classname = "WrappersProto"; +option java_package = "com.google.protobuf"; + +message DoubleValue { + double value = 1; +} + +message FloatValue { + float value = 1; +} + +message Int64Value { + int64 value = 1; +} + +message UInt64Value { + uint64 value = 1; +} + +message Int32Value { + int32 value = 1; +} + +message UInt32Value { + uint32 value = 1; +} + +message BoolValue { + bool value = 1; +} + +message StringValue { + string value = 1; +} + +message BytesValue { + bytes value = 1; +} diff --git a/protocol/proto/greenroom_extension.proto b/protocol/proto/greenroom_extension.proto new file mode 100644 index 00000000..4fc8dbe3 --- /dev/null +++ b/protocol/proto/greenroom_extension.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.greenroom.api.extendedmetadata.v1; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "GreenroomMetadataProto"; +option java_package = "com.spotify.greenroom.api.extendedmetadata.v1.proto"; + +message GreenroomSection { + repeated GreenroomItem items = 1; +} + +message GreenroomItem { + string title = 1; + string description = 2; + repeated GreenroomHost hosts = 3; + int64 start_timestamp = 4; + string deeplink_url = 5; + bool live = 6; +} + +message GreenroomHost { + string name = 1; + string image_url = 2; +} diff --git a/protocol/proto/identity.proto b/protocol/proto/identity.proto new file mode 100644 index 00000000..c6963862 --- /dev/null +++ b/protocol/proto/identity.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.identity.v3; + +import "google/protobuf/field_mask.proto"; +import "google/protobuf/wrappers.proto"; + +option optimize_for = CODE_SIZE; +option java_outer_classname = "IdentityV3"; +option java_package = "com.spotify.identity.proto.v3"; + +message Image { + int32 max_width = 1; + int32 max_height = 2; + string url = 3; +} + +message UserProfile { + google.protobuf.StringValue username = 1; + google.protobuf.StringValue name = 2; + repeated Image images = 3; + google.protobuf.BoolValue verified = 4; + google.protobuf.BoolValue edit_profile_disabled = 5; + google.protobuf.BoolValue report_abuse_disabled = 6; + google.protobuf.BoolValue abuse_reported_name = 7; + google.protobuf.BoolValue abuse_reported_image = 8; + google.protobuf.BoolValue has_spotify_name = 9; + google.protobuf.BoolValue has_spotify_image = 10; + google.protobuf.Int32Value color = 11; + google.protobuf.BoolValue is_private = 12; + google.protobuf.StringValue pronouns = 13; + google.protobuf.StringValue location = 14; + google.protobuf.StringValue bio = 15; + google.protobuf.BoolValue abuse_reported_bio = 17; + google.protobuf.BoolValue edit_name_disabled = 18; + google.protobuf.BoolValue edit_image_disabled = 19; + google.protobuf.BoolValue edit_bio_disabled = 20; + google.protobuf.BoolValue is_kid = 21; +} + +message UserProfileUpdateRequest { + google.protobuf.FieldMask mask = 1; + UserProfile user_profile = 2; + bool skip_emit_events = 3; +} + +message UserProfileChangedEvent { + string userid = 1; + UserProfile user_profile = 2; +} + diff --git a/protocol/proto/image-resolve.proto b/protocol/proto/image-resolve.proto new file mode 100644 index 00000000..d8befe97 --- /dev/null +++ b/protocol/proto/image-resolve.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.imageresolve.proto; + +option java_multiple_files = true; +option java_outer_classname = "ImageResolveProtos"; +option java_package = "com.spotify.imageresolve.proto"; + +message Collection { + bytes id = 1; + + repeated Projection projections = 2; + message Projection { + bytes id = 2; + int32 metadata_index = 3; + int32 url_template_index = 4; + } +} + +message ProjectionMetadata { + int32 width = 2; + int32 height = 3; + bool fetch_online = 4; + bool download_for_offline = 5; +} + +message ProjectionMap { + repeated string url_templates = 1; + repeated ProjectionMetadata projection_metas = 2; + repeated Collection collections = 3; +} diff --git a/protocol/proto/installation_data.proto b/protocol/proto/installation_data.proto new file mode 100644 index 00000000..f0451981 --- /dev/null +++ b/protocol/proto/installation_data.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message InstallationEntity { + int32 file_format_version = 1; + bytes encrypted_part = 2; +} + +message InstallationData { + bytes installation_id = 1; + bytes last_seen_device_id = 2; + int64 monotonic_clock_id = 3; +} diff --git a/protocol/proto/instrumentation_params.proto b/protocol/proto/instrumentation_params.proto new file mode 100644 index 00000000..c317f16d --- /dev/null +++ b/protocol/proto/instrumentation_params.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +option optimize_for = CODE_SIZE; + +message InstrumentationParams { + repeated string interaction_ids = 6; + repeated string page_instance_ids = 7; +} diff --git a/protocol/proto/keyexchange.proto b/protocol/proto/keyexchange.proto index 0907c912..840f5524 100644 --- a/protocol/proto/keyexchange.proto +++ b/protocol/proto/keyexchange.proto @@ -57,6 +57,23 @@ enum Platform { PLATFORM_ONKYO_ARM = 0x15; PLATFORM_QNXNTO_ARM = 0x16; PLATFORM_BCO_ARM = 0x17; + PLATFORM_WEBPLAYER = 0x18; + PLATFORM_WP8_ARM = 0x19; + PLATFORM_WP8_X86 = 0x1a; + PLATFORM_WINRT_ARM = 0x1b; + PLATFORM_WINRT_X86 = 0x1c; + PLATFORM_WINRT_X86_64 = 0x1d; + PLATFORM_FRONTIER = 0x1e; + PLATFORM_AMIGA_PPC = 0x1f; + PLATFORM_NANRADIO_NRX901 = 0x20; + PLATFORM_HARMAN_ARM = 0x21; + PLATFORM_SONY_PS3 = 0x22; + PLATFORM_SONY_PS4 = 0x23; + PLATFORM_IPHONE_ARM64 = 0x24; + PLATFORM_RTEMS_PPC = 0x25; + PLATFORM_GENERIC_PARTNER = 0x26; + PLATFORM_WIN32_X86_64 = 0x27; + PLATFORM_WATCHOS = 0x28; } enum Fingerprint { diff --git a/protocol/proto/lens-model.proto b/protocol/proto/lens-model.proto new file mode 100644 index 00000000..aa85defc --- /dev/null +++ b/protocol/proto/lens-model.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.lens.model.proto; + +option java_package = "com.spotify.lens.model.proto"; +option java_outer_classname = "LensModelProto"; +option optimize_for = CODE_SIZE; + +message Lens { + string identifier = 1; +} + +message LensState { + string identifier = 1; + bytes revision = 2; +} + diff --git a/protocol/proto/lfs_secret_provider.proto b/protocol/proto/lfs_secret_provider.proto new file mode 100644 index 00000000..782d19d0 --- /dev/null +++ b/protocol/proto/lfs_secret_provider.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.lfssecretprovider.proto; + +option optimize_for = CODE_SIZE; + +message GetSecretResponse { + bytes secret = 1; +} diff --git a/protocol/proto/liked_songs_tags_sync_state.proto b/protocol/proto/liked_songs_tags_sync_state.proto new file mode 100644 index 00000000..60bea86a --- /dev/null +++ b/protocol/proto/liked_songs_tags_sync_state.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection.proto; + +option optimize_for = CODE_SIZE; + +message TagsSyncState { + string uri = 1; + bool sync_is_complete = 2; +} diff --git a/protocol/proto/listen_later_cosmos_response.proto b/protocol/proto/listen_later_cosmos_response.proto new file mode 100644 index 00000000..c35615f3 --- /dev/null +++ b/protocol/proto/listen_later_cosmos_response.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.listen_later_cosmos.proto; + + +import "collection/episode_collection_state.proto"; +import "metadata/episode_metadata.proto"; +import "played_state/episode_played_state.proto"; +import "sync/episode_sync_state.proto"; + +option java_package = "spotify.listen_later_cosmos.proto"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; + +message Episode { + optional string header = 1; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_played_state = 5; +} + +message EpisodesResponse { + optional uint32 unfiltered_length = 1; + optional uint32 unranged_length = 2; + repeated Episode episode = 3; + optional string offline_availability = 5; + optional uint32 offline_progress = 6; + optional uint32 status_code = 98; + optional string error = 99; +} diff --git a/protocol/proto/local_bans_storage.proto b/protocol/proto/local_bans_storage.proto new file mode 100644 index 00000000..d40dca71 --- /dev/null +++ b/protocol/proto/local_bans_storage.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.collection.proto.storage; + +option optimize_for = CODE_SIZE; + +message BanItem { + required string item_uri = 1; + required string context_uri = 2; + required int64 timestamp = 3; +} + +message LocalBansTimestamp { + required int64 timestamp = 1; +} + +message Bans { + repeated BanItem items = 1; +} diff --git a/protocol/proto/local_sync_cosmos.proto b/protocol/proto/local_sync_cosmos.proto new file mode 100644 index 00000000..cf6187f7 --- /dev/null +++ b/protocol/proto/local_sync_cosmos.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.local_sync_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message GetDevicesResponse { + repeated Device devices = 1; + message Device { + string name = 1; + string id = 2; + string endpoint = 3; + } +} diff --git a/protocol/proto/local_sync_state.proto b/protocol/proto/local_sync_state.proto new file mode 100644 index 00000000..6c096926 --- /dev/null +++ b/protocol/proto/local_sync_state.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.local_sync_state.proto; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.local_sync_state.proto"; + +message State { + string safe_secret = 1; +} diff --git a/protocol/proto/logging_params.proto b/protocol/proto/logging_params.proto new file mode 100644 index 00000000..545d7b64 --- /dev/null +++ b/protocol/proto/logging_params.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message LoggingParams { + optional int64 command_initiated_time = 1; + optional int64 command_received_time = 2; + repeated string page_instance_ids = 3; + repeated string interaction_ids = 4; + optional string device_identifier = 5; + optional string command_id = 6; +} diff --git a/protocol/proto/mdata.proto b/protocol/proto/mdata.proto new file mode 100644 index 00000000..5045d868 --- /dev/null +++ b/protocol/proto/mdata.proto @@ -0,0 +1,46 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.mdata.proto; + +import "extension_kind.proto"; +import "google/protobuf/any.proto"; + +option cc_enable_arenas = true; +option optimize_for = CODE_SIZE; + +message LocalExtensionQuery { + extendedmetadata.ExtensionKind extension_kind = 1; + repeated string entity_uri = 2; +} + +message LocalBatchedEntityRequest { + repeated LocalExtensionQuery extension_query = 1; +} + +message LocalBatchedExtensionResponse { + message ExtensionHeader { + bool cache_valid = 1; + bool offline_valid = 2; + int32 status_code = 3; + bool is_empty = 4; + int64 cache_expiry_timestamp = 5; + int64 offline_expiry_timestamp = 6; + string etag = 7; + } + + message EntityExtension { + string entity_uri = 1; + ExtensionHeader header = 2; + google.protobuf.Any extension_data = 3; + } + + message Extension { + extendedmetadata.ExtensionKind extension_kind = 1; + repeated EntityExtension entity_extension = 2; + } + + repeated Extension extension = 1; +} + diff --git a/protocol/proto/mdata_cosmos.proto b/protocol/proto/mdata_cosmos.proto new file mode 100644 index 00000000..2639ae9f --- /dev/null +++ b/protocol/proto/mdata_cosmos.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.mdata_cosmos.proto; + +import "extension_kind.proto"; + +option cc_enable_arenas = true; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.mdata.cosmos.proto"; + +message InvalidateCacheRequest { + extendedmetadata.ExtensionKind extension_kind = 1; + repeated string entity_uri = 2; +} + +message InvalidateCacheResponse { +} diff --git a/protocol/proto/mdata_storage.proto b/protocol/proto/mdata_storage.proto new file mode 100644 index 00000000..d293ff7b --- /dev/null +++ b/protocol/proto/mdata_storage.proto @@ -0,0 +1,39 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.mdata.proto.storage; + +import "extension_kind.proto"; +import "google/protobuf/any.proto"; + +option cc_enable_arenas = true; +option optimize_for = CODE_SIZE; + +message CacheEntry { + extendedmetadata.ExtensionKind kind = 1; + google.protobuf.Any extension_data = 2; +} + +message CacheInfo { + int32 status_code = 1; + bool is_empty = 2; + uint64 cache_expiry = 3; + uint64 offline_expiry = 4; + string etag = 5; + fixed64 cache_checksum_lo = 6; + fixed64 cache_checksum_hi = 7; + uint64 last_modified = 8; +} + +message OfflineLock { + uint64 lock_expiry = 1; +} + +message AudioFiles { + string file_id = 1; +} + +message TrackDescriptor { + int32 track_id = 1; +} diff --git a/protocol/proto/media.proto b/protocol/proto/media.proto new file mode 100644 index 00000000..5be102bf --- /dev/null +++ b/protocol/proto/media.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.common.media; + +option java_package = "com.spotify.common.proto"; +option optimize_for = CODE_SIZE; + +enum AudioQuality { + DEFAULT = 0; + LOW = 1; + NORMAL = 2; + HIGH = 3; + VERY_HIGH = 4; + HIFI = 5; + HIFI_24 = 6; +} + diff --git a/protocol/proto/media_format.proto b/protocol/proto/media_format.proto new file mode 100644 index 00000000..c54f6323 --- /dev/null +++ b/protocol/proto/media_format.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum MediaFormat { + FORMAT_UNKNOWN = 0; + FORMAT_OGG_VORBIS_96 = 1; + FORMAT_OGG_VORBIS_160 = 2; + FORMAT_OGG_VORBIS_320 = 3; + FORMAT_MP3_256 = 4; + FORMAT_MP3_320 = 5; + FORMAT_MP3_160 = 6; + FORMAT_MP3_96 = 7; + FORMAT_MP3_160_ENCRYPTED = 8; + FORMAT_AAC_24 = 9; + FORMAT_AAC_48 = 10; + FORMAT_MP4_128 = 11; + FORMAT_MP4_128_DUAL = 12; + FORMAT_MP4_128_CBCS = 13; + FORMAT_MP4_256 = 14; + FORMAT_MP4_256_DUAL = 15; + FORMAT_MP4_256_CBCS = 16; + FORMAT_FLAC_FLAC = 17; + FORMAT_MP4_FLAC = 18; + FORMAT_MP4_Unknown = 19; + FORMAT_MP3_Unknown = 20; +} diff --git a/protocol/proto/media_manifest.proto b/protocol/proto/media_manifest.proto new file mode 100644 index 00000000..b6f32e77 --- /dev/null +++ b/protocol/proto/media_manifest.proto @@ -0,0 +1,63 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.media_manifest.proto; + +option optimize_for = CODE_SIZE; + +message AudioFile { + enum Format { + OGG_VORBIS_96 = 0; + OGG_VORBIS_160 = 1; + OGG_VORBIS_320 = 2; + MP3_256 = 3; + MP3_320 = 4; + MP3_160 = 5; + MP3_96 = 6; + MP3_160_ENC = 7; + AAC_24 = 8; + AAC_48 = 9; + FLAC_FLAC = 16; + } +} + +message File { + message ExternalFile { + string method = 1; + bytes body = 4; + oneof endpoint { + string url = 2; + string service = 3; + } + optional bool disable_range_requests = 5; + } + + message FileIdFile { + string file_id_hex = 1; + AudioFile.Format download_format = 2; + EncryptionType encryption = 3; + } + + message NormalizationParams { + float loudness_db = 1; + float true_peak_db = 2; + } + + int32 bitrate = 3; + string mime_type = 4; + oneof file { + ExternalFile external_file = 1; + FileIdFile file_id_file = 2; + } + optional NormalizationParams normalization_params = 5; +} + +message Files { + repeated File files = 1; +} + +enum EncryptionType { + NONE = 0; + AES = 1; +} diff --git a/protocol/proto/media_type.proto b/protocol/proto/media_type.proto new file mode 100644 index 00000000..a2f9a41b --- /dev/null +++ b/protocol/proto/media_type.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum MediaType { + AUDIO = 0; + VIDEO = 1; + MEDIA_TYPE_AUDIO = 0; + MEDIA_TYPE_VIDEO = 1; + MEDIA_TYPE_UNKNOWN = 2; +} diff --git a/protocol/proto/media_type_node.proto b/protocol/proto/media_type_node.proto new file mode 100644 index 00000000..0d0a5964 --- /dev/null +++ b/protocol/proto/media_type_node.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message MediaTypeNode { + optional string current_uri = 1; + optional string media_type = 2; + optional string media_manifest_id = 3; +} diff --git a/protocol/proto/members_request.proto b/protocol/proto/members_request.proto new file mode 100644 index 00000000..486eff7c --- /dev/null +++ b/protocol/proto/members_request.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message 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..386228ab --- /dev/null +++ b/protocol/proto/members_response.proto @@ -0,0 +1,35 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "playlist_permission.proto"; +import "playlist_user_state.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message Member { + optional User user = 1; + optional bool is_owner = 2; + optional uint32 num_tracks = 3; + optional uint32 num_episodes = 4; + optional FollowState follow_state = 5; + optional playlist_permission.proto.PermissionLevel permission_level = 6; +} + +message PlaylistMembersResponse { + optional string title = 1; + optional uint32 num_total_members = 2; + optional playlist_permission.proto.Capabilities capabilities = 3; + optional playlist_permission.proto.PermissionLevel base_permission_level = 4; + repeated Member members = 5; +} + +enum FollowState { + NONE = 0; + CAN_BE_FOLLOWED = 1; + CAN_BE_UNFOLLOWED = 2; +} diff --git a/protocol/proto/mergedprofile.proto b/protocol/proto/mergedprofile.proto deleted file mode 100644 index e283e1de..00000000 --- a/protocol/proto/mergedprofile.proto +++ /dev/null @@ -1,10 +0,0 @@ -syntax = "proto2"; - -message MergedProfileRequest { -} - -message MergedProfileReply { - optional string username = 0x1; - optional string artistid = 0x2; -} - diff --git a/protocol/proto/messages/discovery/force_discover.proto b/protocol/proto/messages/discovery/force_discover.proto new file mode 100644 index 00000000..22bcb066 --- /dev/null +++ b/protocol/proto/messages/discovery/force_discover.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connect.esperanto.proto; + +option java_package = "com.spotify.connect.esperanto.proto"; + +message ForceDiscoverRequest { + +} + +message ForceDiscoverResponse { + +} diff --git a/protocol/proto/messages/discovery/start_discovery.proto b/protocol/proto/messages/discovery/start_discovery.proto new file mode 100644 index 00000000..d4af9339 --- /dev/null +++ b/protocol/proto/messages/discovery/start_discovery.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.connect.esperanto.proto; + +option java_package = "com.spotify.connect.esperanto.proto"; + +message StartDiscoveryRequest { + +} + +message StartDiscoveryResponse { + +} diff --git a/protocol/proto/metadata.proto b/protocol/proto/metadata.proto index 3812f94e..fa7aec95 100644 --- a/protocol/proto/metadata.proto +++ b/protocol/proto/metadata.proto @@ -1,266 +1,331 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + syntax = "proto2"; -message TopTracks { - optional string country = 0x1; - repeated Track track = 0x2; -} +package spotify.metadata; -message ActivityPeriod { - optional sint32 start_year = 0x1; - optional sint32 end_year = 0x2; - optional sint32 decade = 0x3; -} +option optimize_for = CODE_SIZE; +option java_outer_classname = "Metadata"; +option java_package = "com.spotify.metadata.proto"; message Artist { - optional bytes gid = 0x1; - optional string name = 0x2; - optional sint32 popularity = 0x3; - repeated TopTracks top_track = 0x4; - repeated AlbumGroup album_group = 0x5; - repeated AlbumGroup single_group = 0x6; - repeated AlbumGroup compilation_group = 0x7; - repeated AlbumGroup appears_on_group = 0x8; - repeated string genre = 0x9; - repeated ExternalId external_id = 0xa; - repeated Image portrait = 0xb; - repeated Biography biography = 0xc; - repeated ActivityPeriod activity_period = 0xd; - repeated Restriction restriction = 0xe; - repeated Artist related = 0xf; - optional bool is_portrait_album_cover = 0x10; - optional ImageGroup portrait_group = 0x11; -} - -message AlbumGroup { - repeated Album album = 0x1; -} - -message Date { - optional sint32 year = 0x1; - optional sint32 month = 0x2; - optional sint32 day = 0x3; - optional sint32 hour = 0x4; - optional sint32 minute = 0x5; + reserved 9; + optional bytes gid = 1; + optional string name = 2; + optional sint32 popularity = 3; + repeated TopTracks top_track = 4; + repeated AlbumGroup album_group = 5; + repeated AlbumGroup single_group = 6; + repeated AlbumGroup compilation_group = 7; + repeated AlbumGroup appears_on_group = 8; + repeated ExternalId external_id = 10; + repeated Image portrait = 11; + repeated Biography biography = 12; + repeated ActivityPeriod activity_period = 13; + repeated Restriction restriction = 14; + repeated Artist related = 15; + optional bool is_portrait_album_cover = 16; + optional ImageGroup portrait_group = 17; + repeated SalePeriod sale_period = 18; + repeated Availability availability = 20; } message Album { - optional bytes gid = 0x1; - optional string name = 0x2; - repeated Artist artist = 0x3; - optional Type typ = 0x4; + reserved 8; + + optional bytes gid = 1; + optional string name = 2; + repeated Artist artist = 3; + + optional Type type = 4; enum Type { - ALBUM = 0x1; - SINGLE = 0x2; - COMPILATION = 0x3; - EP = 0x4; + ALBUM = 1; + SINGLE = 2; + COMPILATION = 3; + EP = 4; + AUDIOBOOK = 5; + PODCAST = 6; } - optional string label = 0x5; - optional Date date = 0x6; - optional sint32 popularity = 0x7; - repeated string genre = 0x8; - repeated Image cover = 0x9; - repeated ExternalId external_id = 0xa; - repeated Disc disc = 0xb; - repeated string review = 0xc; - repeated Copyright copyright = 0xd; - repeated Restriction restriction = 0xe; - repeated Album related = 0xf; - repeated SalePeriod sale_period = 0x10; - optional ImageGroup cover_group = 0x11; + + optional string label = 5; + optional Date date = 6; + optional sint32 popularity = 7; + repeated Image cover = 9; + repeated ExternalId external_id = 10; + repeated Disc disc = 11; + repeated string review = 12; + repeated Copyright copyright = 13; + repeated Restriction restriction = 14; + repeated Album related = 15; + repeated SalePeriod sale_period = 16; + optional ImageGroup cover_group = 17; + optional string original_title = 18; + optional string version_title = 19; + optional string type_str = 20; + repeated Availability availability = 23; } message Track { - optional bytes gid = 0x1; - optional string name = 0x2; - optional Album album = 0x3; - repeated Artist artist = 0x4; - optional sint32 number = 0x5; - optional sint32 disc_number = 0x6; - optional sint32 duration = 0x7; - optional sint32 popularity = 0x8; - optional bool explicit = 0x9; - repeated ExternalId external_id = 0xa; - repeated Restriction restriction = 0xb; - repeated AudioFile file = 0xc; - repeated Track alternative = 0xd; - repeated SalePeriod sale_period = 0xe; - repeated AudioFile preview = 0xf; + optional bytes gid = 1; + optional string name = 2; + optional Album album = 3; + repeated Artist artist = 4; + optional sint32 number = 5; + optional sint32 disc_number = 6; + optional sint32 duration = 7; + optional sint32 popularity = 8; + optional bool explicit = 9; + repeated ExternalId external_id = 10; + repeated Restriction restriction = 11; + repeated AudioFile file = 12; + repeated Track alternative = 13; + repeated SalePeriod sale_period = 14; + repeated AudioFile preview = 15; + repeated string tags = 16; + optional int64 earliest_live_timestamp = 17; + optional bool has_lyrics = 18; + repeated Availability availability = 19; + optional Licensor licensor = 21; + repeated string language_of_performance = 22; + optional Audio original_audio = 24; + repeated ContentRating content_rating = 25; + optional string original_title = 27; + optional string version_title = 28; + repeated ArtistWithRole artist_with_role = 32; + optional string canonical_uri = 36; + repeated Video original_video = 38; +} + +message ArtistWithRole { + enum ArtistRole { + ARTIST_ROLE_UNKNOWN = 0; + ARTIST_ROLE_MAIN_ARTIST = 1; + ARTIST_ROLE_FEATURED_ARTIST = 2; + ARTIST_ROLE_REMIXER = 3; + ARTIST_ROLE_ACTOR = 4; + ARTIST_ROLE_COMPOSER = 5; + ARTIST_ROLE_CONDUCTOR = 6; + ARTIST_ROLE_ORCHESTRA = 7; + } + + optional bytes artist_gid = 1; + optional string artist_name = 2; + optional ArtistRole role = 3; +} + +message Show { + optional bytes gid = 1; + optional string name = 2; + optional string description = 64; + optional sint32 deprecated_popularity = 65; + optional string publisher = 66; + optional string language = 67; + optional bool explicit = 68; + optional ImageGroup cover_image = 69; + repeated Episode episode = 70; + repeated Copyright copyright = 71; + repeated Restriction restriction = 72; + repeated string keyword = 73; + + optional MediaType media_type = 74; + enum MediaType { + MIXED = 0; + AUDIO = 1; + VIDEO = 2; + } + + optional ConsumptionOrder consumption_order = 75; + enum ConsumptionOrder { + SEQUENTIAL = 1; + EPISODIC = 2; + RECENT = 3; + } + + repeated Availability availability = 78; + optional string trailer_uri = 83; + optional bool music_and_talk = 85; + optional bool is_audiobook = 89; + optional bool is_creator_channel = 90; +} + +message Episode { + optional bytes gid = 1; + optional string name = 2; + optional sint32 duration = 7; + repeated AudioFile audio = 12; + optional string description = 64; + optional sint32 number = 65; + optional Date publish_time = 66; + optional sint32 deprecated_popularity = 67; + optional ImageGroup cover_image = 68; + optional string language = 69; + optional bool explicit = 70; + optional Show show = 71; + repeated VideoFile video = 72; + repeated VideoFile video_preview = 73; + repeated AudioFile audio_preview = 74; + repeated Restriction restriction = 75; + optional ImageGroup freeze_frame = 76; + repeated string keyword = 77; + optional bool allow_background_playback = 81; + repeated Availability availability = 82; + optional string external_url = 83; + optional Audio original_audio = 84; + + optional Episode.EpisodeType type = 87; + enum EpisodeType { + FULL = 0; + TRAILER = 1; + BONUS = 2; + } + + optional bool music_and_talk = 91; + repeated ContentRating content_rating = 95; + optional bool is_audiobook_chapter = 96; + optional bool is_podcast_short = 97; +} + +message Licensor { + optional bytes uuid = 1; +} + +message Audio { + optional bytes uuid = 1; +} + +message TopTracks { + optional string country = 1; + repeated Track track = 2; +} + +message ActivityPeriod { + optional sint32 start_year = 1; + optional sint32 end_year = 2; + optional sint32 decade = 3; +} + +message AlbumGroup { + repeated Album album = 1; +} + +message Date { + optional sint32 year = 1; + optional sint32 month = 2; + optional sint32 day = 3; + optional sint32 hour = 4; + optional sint32 minute = 5; } message Image { - optional bytes file_id = 0x1; - optional Size size = 0x2; enum Size { - DEFAULT = 0x0; - SMALL = 0x1; - LARGE = 0x2; - XLARGE = 0x3; + DEFAULT = 0; + SMALL = 1; + LARGE = 2; + XLARGE = 3; } - optional sint32 width = 0x3; - optional sint32 height = 0x4; + + optional bytes file_id = 1; + optional Size size = 2; + optional sint32 width = 3; + optional sint32 height = 4; } message ImageGroup { - repeated Image image = 0x1; + repeated Image image = 1; } message Biography { - optional string text = 0x1; - repeated Image portrait = 0x2; - repeated ImageGroup portrait_group = 0x3; + optional string text = 1; + repeated Image portrait = 2; + repeated ImageGroup portrait_group = 3; } message Disc { - optional sint32 number = 0x1; - optional string name = 0x2; - repeated Track track = 0x3; + optional sint32 number = 1; + optional string name = 2; + repeated Track track = 3; } message Copyright { - optional Type typ = 0x1; enum Type { - P = 0x0; - C = 0x1; + P = 0; + C = 1; } - optional string text = 0x2; + + optional Type type = 1; + optional string text = 2; } message Restriction { enum Catalogue { - AD = 0; - SUBSCRIPTION = 1; - CATALOGUE_ALL = 2; - SHUFFLE = 3; - COMMERCIAL = 4; + AD = 0; + SUBSCRIPTION = 1; + CATALOGUE_ALL = 2; + SHUFFLE = 3; + COMMERCIAL = 4; } - enum Type { - STREAMING = 0x0; - } - repeated Catalogue catalogue = 0x1; - optional string countries_allowed = 0x2; - optional string countries_forbidden = 0x3; - optional Type typ = 0x4; - repeated string catalogue_str = 0x5; + enum Type { + STREAMING = 0; + } + + repeated Catalogue catalogue = 1; + optional Type type = 4; + repeated string catalogue_str = 5; + oneof country_restriction { + string countries_allowed = 2; + string countries_forbidden = 3; + } } message Availability { - repeated string catalogue_str = 0x1; - optional Date start = 0x2; + repeated string catalogue_str = 1; + optional Date start = 2; } message SalePeriod { - repeated Restriction restriction = 0x1; - optional Date start = 0x2; - optional Date end = 0x3; + repeated Restriction restriction = 1; + optional Date start = 2; + optional Date end = 3; } message ExternalId { - optional string typ = 0x1; - optional string id = 0x2; + optional string type = 1; + optional string id = 2; } message AudioFile { - optional bytes file_id = 0x1; - optional Format format = 0x2; enum Format { - OGG_VORBIS_96 = 0x0; - OGG_VORBIS_160 = 0x1; - OGG_VORBIS_320 = 0x2; - MP3_256 = 0x3; - MP3_320 = 0x4; - MP3_160 = 0x5; - MP3_96 = 0x6; - MP3_160_ENC = 0x7; - // v4 - // AAC_24 = 0x8; - // AAC_48 = 0x9; - MP4_128_DUAL = 0x8; - OTHER3 = 0x9; - AAC_160 = 0xa; - AAC_320 = 0xb; - MP4_128 = 0xc; - OTHER5 = 0xd; + OGG_VORBIS_96 = 0; + OGG_VORBIS_160 = 1; + OGG_VORBIS_320 = 2; + MP3_256 = 3; + MP3_320 = 4; + MP3_160 = 5; + MP3_96 = 6; + MP3_160_ENC = 7; + AAC_24 = 8; + AAC_48 = 9; + FLAC_FLAC = 16; + XHE_AAC_24 = 18; + XHE_AAC_16 = 19; + XHE_AAC_12 = 20; + FLAC_FLAC_24BIT = 22; } + + optional bytes file_id = 1; + optional Format format = 2; +} + +message Video { + optional bytes gid = 1; } message VideoFile { - optional bytes file_id = 1; + optional bytes file_id = 1; } -// Podcast Protos -message Show { - enum MediaType { - MIXED = 0; - AUDIO = 1; - VIDEO = 2; - } - enum ConsumptionOrder { - SEQUENTIAL = 1; - EPISODIC = 2; - RECENT = 3; - } - enum PassthroughEnum { - UNKNOWN = 0; - NONE = 1; - ALLOWED = 2; - } - optional bytes gid = 0x1; - optional string name = 0x2; - optional string description = 0x40; - optional sint32 deprecated_popularity = 0x41; - optional string publisher = 0x42; - optional string language = 0x43; - optional bool explicit = 0x44; - optional ImageGroup covers = 0x45; - repeated Episode episode = 0x46; - repeated Copyright copyright = 0x47; - repeated Restriction restriction = 0x48; - repeated string keyword = 0x49; - optional MediaType media_type = 0x4A; - optional ConsumptionOrder consumption_order = 0x4B; - optional bool interpret_restriction_using_geoip = 0x4C; - repeated Availability availability = 0x4E; - optional string country_of_origin = 0x4F; - repeated Category categories = 0x50; - optional PassthroughEnum passthrough = 0x51; -} - -message Episode { - optional bytes gid = 0x1; - optional string name = 0x2; - optional sint32 duration = 0x7; - optional sint32 popularity = 0x8; - repeated AudioFile file = 0xc; - optional string description = 0x40; - optional sint32 number = 0x41; - optional Date publish_time = 0x42; - optional sint32 deprecated_popularity = 0x43; - optional ImageGroup covers = 0x44; - optional string language = 0x45; - optional bool explicit = 0x46; - optional Show show = 0x47; - repeated VideoFile video = 0x48; - repeated VideoFile video_preview = 0x49; - repeated AudioFile audio_preview = 0x4A; - repeated Restriction restriction = 0x4B; - optional ImageGroup freeze_frame = 0x4C; - repeated string keyword = 0x4D; - // Order of these two flags might be wrong! - optional bool suppress_monetization = 0x4E; - optional bool interpret_restriction_using_geoip = 0x4F; - - optional bool allow_background_playback = 0x51; - repeated Availability availability = 0x52; - optional string external_url = 0x53; - optional OriginalAudio original_audio = 0x54; -} - -message Category { - optional string name = 0x1; - repeated Category subcategories = 0x2; -} - -message OriginalAudio { - optional bytes uuid = 0x1; +message ContentRating { + optional string country = 1; + repeated string tag = 2; } diff --git a/protocol/proto/metadata/album_metadata.proto b/protocol/proto/metadata/album_metadata.proto new file mode 100644 index 00000000..8df63e7d --- /dev/null +++ b/protocol/proto/metadata/album_metadata.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message AlbumArtistMetadata { + optional string link = 1; + optional string name = 2; +} + +message AlbumMetadata { + repeated AlbumArtistMetadata artists = 1; + optional string link = 2; + optional string name = 3; + repeated string copyright = 4; + optional ImageGroup covers = 5; + optional uint32 year = 6; + optional uint32 num_discs = 7; + optional uint32 num_tracks = 8; + optional bool playability = 9; + optional bool is_premium_only = 10; +} diff --git a/protocol/proto/metadata/artist_metadata.proto b/protocol/proto/metadata/artist_metadata.proto new file mode 100644 index 00000000..8521fdde --- /dev/null +++ b/protocol/proto/metadata/artist_metadata.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ArtistMetadata { + optional string link = 1; + optional string name = 2; + optional bool is_various_artists = 3; + optional ImageGroup portraits = 4; +} diff --git a/protocol/proto/metadata/episode_metadata.proto b/protocol/proto/metadata/episode_metadata.proto new file mode 100644 index 00000000..fa77b59d --- /dev/null +++ b/protocol/proto/metadata/episode_metadata.proto @@ -0,0 +1,65 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/extension.proto"; +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message EpisodeShowMetadata { + optional string link = 1; + optional string name = 2; + optional string publisher = 3; + optional ImageGroup covers = 4; +} + +message EpisodeMetadata { + reserved 20; + reserved 21; + + optional EpisodeShowMetadata show = 1; + optional string link = 2; + optional string name = 3; + optional uint32 length = 4; + optional ImageGroup covers = 5; + optional string manifest_id = 6; + optional string description = 7; + optional int64 publish_date = 8; + optional ImageGroup freeze_frames = 9; + optional string language = 10; + optional bool available = 11; + + optional MediaType media_type_enum = 12; + enum MediaType { + VODCAST = 0; + AUDIO = 1; + VIDEO = 2; + } + + optional int32 number = 13; + optional bool backgroundable = 14; + optional string preview_manifest_id = 15; + optional bool is_explicit = 16; + optional string preview_id = 17; + + optional EpisodeType episode_type = 18; + enum EpisodeType { + UNKNOWN = 0; + FULL = 1; + TRAILER = 2; + BONUS = 3; + } + + optional bool is_music_and_talk = 19; + repeated Extension extension = 22; + optional bool is_19_plus_only = 23; + optional bool is_book_chapter = 24; + optional bool is_podcast_short = 25; + optional bool is_curated = 26; +} + diff --git a/protocol/proto/metadata/extension.proto b/protocol/proto/metadata/extension.proto new file mode 100644 index 00000000..7a4b4679 --- /dev/null +++ b/protocol/proto/metadata/extension.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "extension_kind.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message Extension { + optional extendedmetadata.ExtensionKind extension_kind = 1; + optional bytes data = 2; +} diff --git a/protocol/proto/metadata/image_group.proto b/protocol/proto/metadata/image_group.proto new file mode 100644 index 00000000..77924904 --- /dev/null +++ b/protocol/proto/metadata/image_group.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ImageGroup { + optional string standard_link = 1; + optional string small_link = 2; + optional string large_link = 3; + optional string xlarge_link = 4; +} diff --git a/protocol/proto/metadata/show_metadata.proto b/protocol/proto/metadata/show_metadata.proto new file mode 100644 index 00000000..76a4bb5f --- /dev/null +++ b/protocol/proto/metadata/show_metadata.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/extension.proto"; +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ShowMetadata { + optional string link = 1; + optional string name = 2; + optional string description = 3; + optional uint32 popularity = 4; + optional string publisher = 5; + optional string language = 6; + optional bool is_explicit = 7; + optional ImageGroup covers = 8; + optional uint32 num_episodes = 9; + optional string consumption_order = 10; + optional int32 media_type_enum = 11; + repeated string copyright = 12; + optional string trailer_uri = 13; + optional bool is_music_and_talk = 14; + repeated Extension extension = 15; + optional bool is_book = 16; + optional bool is_creator_channel = 17; +} diff --git a/protocol/proto/metadata/track_metadata.proto b/protocol/proto/metadata/track_metadata.proto new file mode 100644 index 00000000..9916b797 --- /dev/null +++ b/protocol/proto/metadata/track_metadata.proto @@ -0,0 +1,60 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "metadata/extension.proto"; +import "metadata/image_group.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message TrackAlbumArtistMetadata { + optional string link = 1; + optional string name = 2; +} + +message TrackAlbumMetadata { + optional TrackAlbumArtistMetadata artist = 1; + optional string link = 2; + optional string name = 3; + optional ImageGroup covers = 4; +} + +message TrackArtistMetadata { + optional string link = 1; + optional string name = 2; + optional ImageGroup portraits = 3; +} + +message TrackDescriptor { + optional string name = 1; + optional float weight = 2; +} + +message TrackMetadata { + optional TrackAlbumMetadata album = 1; + repeated TrackArtistMetadata artist = 2; + optional string link = 3; + optional string name = 4; + optional uint32 length = 5; + optional bool playable = 6; + optional uint32 disc_number = 7; + optional uint32 track_number = 8; + optional bool is_explicit = 9; + optional string preview_id = 10; + optional bool is_local = 11; + optional bool playable_local_track = 12; + optional bool has_lyrics = 13; + optional bool is_premium_only = 14; + optional bool locally_playable = 15; + optional string playable_track_link = 16; + optional uint32 popularity = 17; + optional bool is_19_plus_only = 18; + repeated TrackDescriptor track_descriptors = 19; + repeated Extension extension = 20; + optional bool is_curated = 21; + optional bool to_be_obfuscated = 22; +} diff --git a/protocol/proto/metadata_cosmos.proto b/protocol/proto/metadata_cosmos.proto new file mode 100644 index 00000000..bc2b2a42 --- /dev/null +++ b/protocol/proto/metadata_cosmos.proto @@ -0,0 +1,30 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.metadata_cosmos.proto; + +import "metadata.proto"; + +option optimize_for = CODE_SIZE; +option java_outer_classname = "MetadataCosmos"; +option java_package = "com.spotify.metadata.cosmos.proto"; + +message MetadataItem { + oneof item { + sint32 error = 1; + metadata.Artist artist = 2; + metadata.Album album = 3; + metadata.Track track = 4; + metadata.Show show = 5; + metadata.Episode episode = 6; + } +} + +message MultiResponse { + repeated MetadataItem items = 1; +} + +message MultiRequest { + repeated string uris = 1; +} diff --git a/protocol/proto/metadata_esperanto.proto b/protocol/proto/metadata_esperanto.proto new file mode 100644 index 00000000..26ec73cf --- /dev/null +++ b/protocol/proto/metadata_esperanto.proto @@ -0,0 +1,24 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.metadata_esperanto.proto; + +import "metadata_cosmos.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.metadata.esperanto.proto"; + +service ClassicMetadataService { + rpc GetEntity(GetEntityRequest) returns (GetEntityResponse); + rpc MultigetEntity(metadata_cosmos.proto.MultiRequest) returns (metadata_cosmos.proto.MultiResponse); +} + +message GetEntityRequest { + string uri = 1; +} + +message GetEntityResponse { + metadata_cosmos.proto.MetadataItem item = 1; +} diff --git a/protocol/proto/mod.rs b/protocol/proto/mod.rs index 9dfc8c92..24cf4052 100644 --- a/protocol/proto/mod.rs +++ b/protocol/proto/mod.rs @@ -1,4 +1,2 @@ // generated protobuf files will be included here. See build.rs for details -#![allow(renamed_and_removed_lints)] - include!(env!("PROTO_MOD_RS")); diff --git a/protocol/proto/modification_request.proto b/protocol/proto/modification_request.proto new file mode 100644 index 00000000..f8da2506 --- /dev/null +++ b/protocol/proto/modification_request.proto @@ -0,0 +1,70 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message ModificationRequest { + optional string operation = 1; + optional string before = 2; + optional string after = 3; + optional string name = 4; + optional bool playlist = 5; + + optional Attributes attributes = 6; + message Attributes { + optional bool published = 1; + optional bool collaborative = 2; + optional string name = 3; + optional string description = 4; + optional string imageUri = 5; + optional string picture = 6; + optional string ai_curation_reference_id = 7; + optional PublishedState published_state = 8; + } + + repeated string uris = 7; + repeated string rows = 8; + optional bool contents = 9; + optional string item_id = 10; + repeated ListAttributeKind attributes_to_clear = 11; + optional CreateItemKind create_item_kind = 12; +} + +message ModificationResponse { + optional bool success = 1; + optional string uri = 2; +} + +enum ListAttributeKind { + LIST_UNKNOWN = 0; + LIST_NAME = 1; + LIST_DESCRIPTION = 2; + LIST_PICTURE = 3; + LIST_COLLABORATIVE = 4; + LIST_PL3_VERSION = 5; + LIST_DELETED_BY_OWNER = 6; + LIST_CLIENT_ID = 10; + LIST_FORMAT = 11; + LIST_FORMAT_ATTRIBUTES = 12; + LIST_PICTURE_SIZE = 13; + LIST_SEQUENCE_CONTEXT_TEMPLATE = 14; + LIST_AI_CURATION_REFERENCE_ID = 15; +} + +enum PublishedState { + PUBLISHED_STATE_UNSPECIFIED = 0; + PUBLISHED_STATE_NOT_PUBLISHED = 1; + PUBLISHED_STATE_PUBLISHED = 2; +} + +enum CreateItemKind { + CREATE_ITEM_KIND_UNSPECIFIED = 0; + CREATE_ITEM_KIND_PLAYLIST = 1; + CREATE_ITEM_KIND_FOLDER = 2; +} + diff --git a/protocol/proto/net-fortune.proto b/protocol/proto/net-fortune.proto new file mode 100644 index 00000000..62ba5e09 --- /dev/null +++ b/protocol/proto/net-fortune.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.netfortune.proto; + +option optimize_for = CODE_SIZE; + +message NetFortuneResponse { + int32 advised_audio_bitrate = 1; +} + +message NetFortuneV2Response { + string predict_id = 1; + int32 estimated_max_bitrate = 2; + optional int32 advised_prefetch_bitrate_metered = 3; + optional int32 advised_prefetch_bitrate_non_metered = 4; +} diff --git a/protocol/proto/offline.proto b/protocol/proto/offline.proto new file mode 100644 index 00000000..f84a73c9 --- /dev/null +++ b/protocol/proto/offline.proto @@ -0,0 +1,90 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.offline.proto; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + +option optimize_for = CODE_SIZE; + +message Capacity { + double total_space = 1; + double free_space = 2; + double offline_space = 3; + uint64 track_count = 4; + uint64 episode_count = 5; +} + +message Capabilities { + bool remote_downloads_enabled = 1; +} + +message Device { + string device_id = 1; + string cache_id = 2; + string name = 3; + int32 type = 4; + int32 platform = 5; + bool offline_enabled = 6; + Capacity capacity = 7; + Capabilities capabilities = 8; + google.protobuf.Timestamp updated_at = 9; + google.protobuf.Timestamp last_seen_at = 10; + bool removal_pending = 11; + string client_id = 12; +} + +message Restrictions { + google.protobuf.Duration allowed_duration_tracks = 1; + uint64 max_tracks = 2; + google.protobuf.Duration allowed_duration_episodes = 3; + uint64 max_episodes = 4; + google.protobuf.Duration allowed_duration_abp_chapters = 5; +} + +message Resource { + string uri = 1; + ResourceState state = 2; + int32 progress = 3; + google.protobuf.Timestamp updated_at = 4; + string failure_message = 5; +} + +message DeviceKey { + string user_id = 1; + string device_id = 2; + string cache_id = 3; +} + +message ResourceForDevice { + string device_id = 1; + string cache_id = 2; + Resource resource = 3; +} + +message ResourceOperation { + enum Operation { + INVALID = 0; + ADD = 1; + REMOVE = 2; + } + + Operation operation = 2; + string uri = 3; +} + +message ResourceHistoryItem { + repeated ResourceOperation operations = 1; + google.protobuf.Timestamp server_time = 2; +} + +enum ResourceState { + UNSPECIFIED = 0; + REQUESTED = 1; + PENDING = 2; + DOWNLOADING = 3; + DOWNLOADED = 4; + FAILURE = 5; +} diff --git a/protocol/proto/offline_playlists_containing.proto b/protocol/proto/offline_playlists_containing.proto new file mode 100644 index 00000000..7573f493 --- /dev/null +++ b/protocol/proto/offline_playlists_containing.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option objc_class_prefix = "SPTPlaylist"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message OfflinePlaylistContainingItem { + required string playlist_link = 1; + optional string playlist_name = 2; +} + +message OfflinePlaylistsContainingItemResponse { + repeated OfflinePlaylistContainingItem playlists = 1; +} diff --git a/protocol/proto/on_demand_in_free_reason.proto b/protocol/proto/on_demand_in_free_reason.proto new file mode 100644 index 00000000..164d22aa --- /dev/null +++ b/protocol/proto/on_demand_in_free_reason.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.on_demand_set.proto; + +option optimize_for = CODE_SIZE; + +enum OnDemandInFreeReason { + UNKNOWN = 0; + NOT_ON_DEMAND = 1; + ON_DEMAND = 2; + ON_DEMAND_EPISODES_ONLY = 3; + ON_DEMAND_NON_MUSIC_ONLY = 4; +} diff --git a/protocol/proto/on_demand_set_cosmos_request.proto b/protocol/proto/on_demand_set_cosmos_request.proto new file mode 100644 index 00000000..ebbd0f53 --- /dev/null +++ b/protocol/proto/on_demand_set_cosmos_request.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.on_demand_set_cosmos.proto; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.on_demand_set.proto"; + +message Set { + repeated string uris = 1; +} + +message Temporary { + optional string uri = 1; + optional int64 valid_for_in_seconds = 2; +} diff --git a/protocol/proto/on_demand_set_cosmos_response.proto b/protocol/proto/on_demand_set_cosmos_response.proto new file mode 100644 index 00000000..9a17849b --- /dev/null +++ b/protocol/proto/on_demand_set_cosmos_response.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..9633bb26 --- /dev/null +++ b/protocol/proto/on_demand_set_response.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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/pause_resume_origin.proto b/protocol/proto/pause_resume_origin.proto new file mode 100644 index 00000000..b65d3db2 --- /dev/null +++ b/protocol/proto/pause_resume_origin.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message PauseResumeOrigin { + optional string feature_identifier = 1; +} + diff --git a/protocol/proto/pending_event_entity.proto b/protocol/proto/pending_event_entity.proto new file mode 100644 index 00000000..965b8cab --- /dev/null +++ b/protocol/proto/pending_event_entity.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.pending_events.proto; + +option optimize_for = CODE_SIZE; + +message PendingEventEntity { + string event_name = 1; + bytes payload = 2; + string username = 3; +} diff --git a/protocol/proto/perf_metrics_service.proto b/protocol/proto/perf_metrics_service.proto new file mode 100644 index 00000000..484bd321 --- /dev/null +++ b/protocol/proto/perf_metrics_service.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.perf_metrics.esperanto.proto; + +option java_package = "com.spotify.perf_metrics.esperanto.proto"; + +service PerfMetricsService { + rpc TerminateState(PerfMetricsRequest) returns (PerfMetricsResponse); +} + +message PerfMetricsRequest { + string terminal_state = 1; + bool foreground_startup = 2; +} + +message PerfMetricsResponse { + bool success = 1; +} diff --git a/protocol/proto/pin_request.proto b/protocol/proto/pin_request.proto new file mode 100644 index 00000000..bbc388d7 --- /dev/null +++ b/protocol/proto/pin_request.proto @@ -0,0 +1,47 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option java_package = "spotify.your_library.esperanto.proto"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; + +message PinRequest { + string uri = 1; + oneof position { + string after_uri = 2; + string before_uri = 3; + bool first = 4; + } +} + +message MovePinRequest { + string move_uri = 1; + oneof position { + string after_uri = 2; + string before_uri = 3; + bool first = 4; + } +} + +message PinResponse { + enum PinStatus { + UNKNOWN = 0; + PINNED = 1; + NOT_PINNED = 2; + } + + PinStatus status = 1; + bool has_maximum_pinned_items = 2; + int32 maximum_pinned_items = 3; + uint32 status_code = 98; + string error = 99; +} + +message PinItem { + string uri = 1; + bool in_library = 2; +} + diff --git a/protocol/proto/play_history.proto b/protocol/proto/play_history.proto new file mode 100644 index 00000000..f2a4a789 --- /dev/null +++ b/protocol/proto/play_history.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +option optimize_for = CODE_SIZE; + +message PlayHistory { + message Item { + optional string context_id = 1; + optional string uid = 2; + optional bool disliked = 3; + repeated transfer.PlayHistory.Item children = 4; + } + + repeated transfer.PlayHistory.Item backward_items = 1; + repeated transfer.PlayHistory.Item forward_items = 2; +} + diff --git a/protocol/proto/play_origin.proto b/protocol/proto/play_origin.proto new file mode 100644 index 00000000..02903cec --- /dev/null +++ b/protocol/proto/play_origin.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message PlayOrigin { + optional string feature_identifier = 1; + optional string feature_version = 2; + optional string view_uri = 3; + optional string external_referrer = 4; + optional string referrer_identifier = 5; + optional string device_identifier = 6; + repeated string feature_classes = 7; + optional string restriction_identifier = 8; +} diff --git a/protocol/proto/play_queue_node.proto b/protocol/proto/play_queue_node.proto new file mode 100644 index 00000000..bf763dfd --- /dev/null +++ b/protocol/proto/play_queue_node.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_track.proto"; +import "track_instance.proto"; +import "track_instantiator.proto"; + +option optimize_for = CODE_SIZE; + +message PlayQueueNode { + repeated ContextTrack queue = 1; + optional TrackInstance instance = 2; + optional TrackInstantiator instantiator = 3; + optional uint32 next_uid = 4; + optional sint32 iteration = 5; + optional bool delay_enqueued_tracks = 6; +} diff --git a/protocol/proto/play_reason.proto b/protocol/proto/play_reason.proto new file mode 100644 index 00000000..c124ebae --- /dev/null +++ b/protocol/proto/play_reason.proto @@ -0,0 +1,34 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum PlayReason { + PLAY_REASON_UNKNOWN = 0; + PLAY_REASON_APP_LOAD = 1; + PLAY_REASON_BACK_BTN = 2; + PLAY_REASON_CLICK_ROW = 3; + PLAY_REASON_CLICK_SIDE = 4; + PLAY_REASON_END_PLAY = 5; + PLAY_REASON_FWD_BTN = 6; + PLAY_REASON_INTERRUPTED = 7; + PLAY_REASON_LOGOUT = 8; + PLAY_REASON_PLAY_BTN = 9; + PLAY_REASON_POPUP = 10; + PLAY_REASON_REMOTE = 11; + PLAY_REASON_SONG_DONE = 12; + PLAY_REASON_TRACK_DONE = 13; + PLAY_REASON_TRACK_ERROR = 14; + PLAY_REASON_PREVIEW = 15; + PLAY_REASON_URI_OPEN = 16; + PLAY_REASON_BACKGROUNDED = 17; + PLAY_REASON_OFFLINE = 18; + PLAY_REASON_UNEXPECTED_EXIT = 19; + PLAY_REASON_UNEXPECTED_EXIT_WHILE_PAUSED = 20; + PLAY_REASON_SWITCHED_TO_AUDIO = 21; + PLAY_REASON_SWITCHED_TO_VIDEO = 22; +} diff --git a/protocol/proto/playback.proto b/protocol/proto/playback.proto new file mode 100644 index 00000000..06dcfcc9 --- /dev/null +++ b/protocol/proto/playback.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message Playback { + optional int64 timestamp = 1; + optional int32 position_as_of_timestamp = 2; + optional double playback_speed = 3; + optional bool is_paused = 4; + optional ContextTrack current_track = 5; + optional ContextTrack associated_current_track = 6; + optional int32 associated_position_as_of_timestamp = 7; +} diff --git a/protocol/proto/playback_cosmos.proto b/protocol/proto/playback_cosmos.proto new file mode 100644 index 00000000..b2ae4f96 --- /dev/null +++ b/protocol/proto/playback_cosmos.proto @@ -0,0 +1,106 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playback_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message VolumeRequest { + oneof source_or_system { + VolumeChangeSource source = 1; + bool system_initiated = 4; + } + + oneof action { + double volume = 2; + Step step = 3; + } + + enum Step { + option allow_alias = true; + up = 0; + UP = 0; + down = 1; + DOWN = 1; + } +} + +message VolumeResponse { + double volume = 1; +} + +message VolumeSubResponse { + double volume = 1; + VolumeChangeSource source = 2; + bool system_initiated = 3; +} + +message PositionResponseV1 { + int32 position = 1; +} + +message PositionResponseV2 { + int64 position = 1; +} + +message InfoResponse { + bool has_info = 1; + uint64 length_ms = 2; + uint64 position_ms = 3; + bool playing = 4; + bool buffering = 5; + int32 error = 6; + string file_id = 7; + string file_type = 8; + string resolved_content_url = 9; + int32 file_bitrate = 10; + string codec_name = 11; + double playback_speed = 12; + float gain_adjustment = 13; + bool has_loudness = 14; + float loudness = 15; + string strategy = 17; + int32 target_bitrate = 18; + int32 advised_bitrate = 19; + bool target_file_available = 20; + + reserved 16; +} + +message FormatsResponse { + repeated Format formats = 1; + message Format { + string enum_key = 1; + uint32 enum_value = 2; + bool supported = 3; + uint32 bitrate = 4; + string mime_type = 5; + } +} + +message GetFilesResponse { + repeated File files = 1; + message File { + string file_id = 1; + string format = 2; + uint32 bitrate = 3; + uint32 format_enum = 4; + } +} + +message DuckRequest { + Action action = 2; + enum Action { + START = 0; + STOP = 1; + } + + double volume = 3; + uint32 fade_duration_ms = 4; +} + +enum VolumeChangeSource { + USER = 0; + SYSTEM = 1; +} diff --git a/protocol/proto/playback_esperanto.proto b/protocol/proto/playback_esperanto.proto new file mode 100644 index 00000000..80ae1a7a --- /dev/null +++ b/protocol/proto/playback_esperanto.proto @@ -0,0 +1,148 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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 ConnectLoggingParams { + repeated string interaction_ids = 1; + repeated string page_instance_ids = 2; +} + +message GetVolumeResponse { + Status status = 1; + double volume = 2; +} + +message GetRawVolumeResponse { + Status status = 1; + int32 volume = 2; +} + +message SubVolumeResponse { + Status status = 1; + double volume = 2; + VolumeChangeSource source = 3; +} + +message SubRawVolumeResponse { + Status status = 1; + int32 volume = 2; + VolumeChangeSource source = 3; +} + +message SetVolumeRequest { + VolumeChangeSource source = 1; + double volume = 2; + ConnectLoggingParams connect_logging_params = 3; +} + +message SetRawVolumeRequest { + VolumeChangeSource source = 1; + int32 volume = 2; + ConnectLoggingParams connect_logging_params = 3; +} + +message NudgeVolumeRequest { + VolumeChangeSource source = 1; + ConnectLoggingParams connect_logging_params = 2; +} + +message PlaybackInfoResponse { + reserved 3; + reserved 16; + Status status = 1; + uint64 length_ms = 2; + 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; + string audio_id = 21; +} + +message GetFormatsResponse { + message Format { + string enum_key = 1; + uint32 enum_value = 2; + bool supported = 3; + uint32 bitrate = 4; + string mime_type = 5; + } + + repeated GetFormatsResponse.Format formats = 1; +} + +message SubPositionRequest { + uint64 position = 1; +} + +message SubPositionResponse { + Status status = 1; + uint64 position = 2; +} + +message GetFilesRequest { + string uri = 1; +} + +message GetFilesResponse { + message File { + string file_id = 1; + string format = 2; + uint32 bitrate = 3; + uint32 format_enum = 4; + } + + GetFilesStatus status = 1; + repeated File files = 2; +} + +message DuckRequest { + enum Action { + START = 0; + STOP = 1; + } + + Action action = 2; + 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; + CONNECT = 2; +} diff --git a/protocol/proto/playback_platform.proto b/protocol/proto/playback_platform.proto new file mode 100644 index 00000000..5f50bd95 --- /dev/null +++ b/protocol/proto/playback_platform.proto @@ -0,0 +1,90 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.playback_platform.proto; + +import "media_manifest.proto"; + +option optimize_for = CODE_SIZE; + +message Media { + string id = 1; + int32 start_position = 6; + int32 stop_position = 7; + + oneof source { + string audio_id = 2; + string episode_id = 3; + string track_id = 4; + media_manifest.proto.Files files = 5; + } +} + +message Annotation { + map metadata = 2; +} + +message PlaybackControl { + +} + +message Context { + string id = 2; + string type = 3; + + reserved 1; +} + +message Timeline { + repeated MediaTrack media_tracks = 1; + message MediaTrack { + repeated Item items = 1; + message Item { + repeated Annotation annotations = 3; + repeated PlaybackControl controls = 4; + + oneof content { + Context context = 1; + Media media = 2; + } + } + } +} + +message PageId { + Context context = 1; + int32 index = 2; +} + +message PagePath { + repeated PageId segments = 1; +} + +message Page { + Header header = 1; + message Header { + int32 status_code = 1; + int32 num_pages = 2; + } + + PageId page_id = 2; + Timeline timeline = 3; +} + +message PageList { + repeated Page pages = 1; +} + +message PageMultiGetRequest { + repeated PageId page_ids = 1; +} + +message PageMultiGetResponse { + repeated Page pages = 1; +} + +message ContextPagePathState { + PagePath path = 1; + repeated int32 media_track_item_index = 3; +} diff --git a/protocol/proto/playback_segments.proto b/protocol/proto/playback_segments.proto new file mode 100644 index 00000000..1f6f6ea8 --- /dev/null +++ b/protocol/proto/playback_segments.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments.playback; + +import "podcast_segments.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PlaybackSegmentsProto"; +option java_package = "com.spotify.podcastsegments.playback.proto"; + +message PlaybackSegments { + repeated PlaybackSegment playback_segments = 1; +} diff --git a/protocol/proto/playback_stack.proto b/protocol/proto/playback_stack.proto new file mode 100644 index 00000000..81ebf752 --- /dev/null +++ b/protocol/proto/playback_stack.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option java_package = "com.spotify.stream_reporting_esperanto.proto"; +option objc_class_prefix = "ESP"; + +enum PlaybackStack { + BOOMBOX = 0; + BETAMAX = 1; + UNKNOWN = 2; +} + diff --git a/protocol/proto/playback_stack_v2.proto b/protocol/proto/playback_stack_v2.proto new file mode 100644 index 00000000..302b1bb4 --- /dev/null +++ b/protocol/proto/playback_stack_v2.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option java_package = "com.spotify.stream_reporting_esperanto.proto"; +option objc_class_prefix = "ESP"; + +enum PlaybackStackV2 { + PLAYBACK_STACK_UNKNOWN = 0; + PLAYBACK_STACK_BOOMBOX = 1; + PLAYBACK_STACK_BETAMAX = 2; + PLAYBACK_STACK_KUBRICK = 3; +} + diff --git a/protocol/proto/playback_state.proto b/protocol/proto/playback_state.proto new file mode 100644 index 00000000..aaf34a77 --- /dev/null +++ b/protocol/proto/playback_state.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option java_package = "com.spotify.stream_reporting_esperanto.proto"; +option objc_class_prefix = "ESP"; + +enum PlaybackState { + ACTIVE = 0; + PAUSED = 1; + SUSPENDED = 2; + INVALID_PLAYBACK_STATE = 3; +} + diff --git a/protocol/proto/played_state.proto b/protocol/proto/played_state.proto new file mode 100644 index 00000000..f1371578 --- /dev/null +++ b/protocol/proto/played_state.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.played_state.proto; + +option optimize_for = CODE_SIZE; + +message PlayedStateItem { + optional string show_uri = 1; + optional string episode_uri = 2; + optional int32 resume_point = 3; + optional int32 last_played_at = 4; + optional bool is_latest = 5; + optional bool has_been_fully_played = 6; + optional bool has_been_synced = 7; + optional int32 episode_length = 8; +} + +message PlayedStateItems { + repeated PlayedStateItem item = 1; + optional uint64 last_server_sync_timestamp = 2; +} diff --git a/protocol/proto/played_state/episode_played_state.proto b/protocol/proto/played_state/episode_played_state.proto new file mode 100644 index 00000000..6a90905d --- /dev/null +++ b/protocol/proto/played_state/episode_played_state.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "played_state/playability_restriction.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message EpisodePlayState { + optional uint32 time_left = 1; + optional bool is_playable = 2; + optional bool is_played = 3; + optional uint32 last_played_at = 4; + optional PlayabilityRestriction playability_restriction = 5 [default = UNKNOWN]; +} diff --git a/protocol/proto/played_state/playability_restriction.proto b/protocol/proto/played_state/playability_restriction.proto new file mode 100644 index 00000000..b1471fc0 --- /dev/null +++ b/protocol/proto/played_state/playability_restriction.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +enum PlayabilityRestriction { + UNKNOWN = 0; + NO_RESTRICTION = 1; + EXPLICIT_CONTENT = 2; + AGE_RESTRICTED = 3; + NOT_IN_CATALOGUE = 4; + NOT_AVAILABLE_OFFLINE = 5; + PREMIUM_ONLY = 6; +} diff --git a/protocol/proto/played_state/show_played_state.proto b/protocol/proto/played_state/show_played_state.proto new file mode 100644 index 00000000..912c7dbf --- /dev/null +++ b/protocol/proto/played_state/show_played_state.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "played_state/playability_restriction.proto"; + +option objc_class_prefix = "SPTCosmosUtil"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ShowPlayState { + enum Label { + UNKNOWN_LABEL = 0; + NOT_STARTED = 1; + IN_PROGRESS = 2; + COMPLETED = 3; + } + + optional string latest_played_episode_link = 1; + optional uint64 played_time = 2; + optional bool is_playable = 3; + optional PlayabilityRestriction playability_restriction = 4 [default = UNKNOWN]; + optional Label label = 5; + optional uint32 played_percentage = 6; + optional string resume_episode_link = 7; +} diff --git a/protocol/proto/played_state/track_played_state.proto b/protocol/proto/played_state/track_played_state.proto new file mode 100644 index 00000000..2f26c774 --- /dev/null +++ b/protocol/proto/played_state/track_played_state.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +import "played_state/playability_restriction.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message TrackPlayState { + optional bool is_playable = 1; + optional PlayabilityRestriction playability_restriction = 2 [default = UNKNOWN]; +} diff --git a/protocol/proto/playedstate.proto b/protocol/proto/playedstate.proto new file mode 100644 index 00000000..fefce00f --- /dev/null +++ b/protocol/proto/playedstate.proto @@ -0,0 +1,40 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify_playedstate.proto; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playedstate.proto"; + +message PlayedStateItem { + optional Type type = 1; + optional bytes uri = 2; + optional int64 client_timestamp = 3; + optional int32 play_position = 4; + optional bool played = 5; + optional int32 duration = 6; +} + +message PlayedState { + optional int64 server_timestamp = 1; + optional bool truncated = 2; + repeated PlayedStateItem state = 3; +} + +message PlayedStateItemList { + repeated PlayedStateItem state = 1; +} + +message ContentId { + optional Type type = 1; + optional bytes uri = 2; +} + +message ContentIdList { + repeated ContentId contentIds = 1; +} + +enum Type { + EPISODE = 0; +} diff --git a/protocol/proto/player.proto b/protocol/proto/player.proto new file mode 100644 index 00000000..3b5716c3 --- /dev/null +++ b/protocol/proto/player.proto @@ -0,0 +1,164 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.connectstate; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.connectstate.model"; + +message PlayerState { + reserved 26; + reserved 27; + reserved 28; + reserved 29; + reserved 30; + reserved 31; + reserved 34; + + int64 timestamp = 1; + string context_uri = 2; + string context_url = 3; + Restrictions context_restrictions = 4; + PlayOrigin play_origin = 5; + ContextIndex index = 6; + ProvidedTrack track = 7; + string playback_id = 8; + double playback_speed = 9; + int64 position_as_of_timestamp = 10; + int64 duration = 11; + bool is_playing = 12; + bool is_paused = 13; + bool is_buffering = 14; + bool is_system_initiated = 15; + ContextPlayerOptions options = 16; + Restrictions restrictions = 17; + Suppressions suppressions = 18; + repeated ProvidedTrack prev_tracks = 19; + repeated ProvidedTrack next_tracks = 20; + map context_metadata = 21; + map page_metadata = 22; + string session_id = 23; + string queue_revision = 24; + int64 position = 25; + PlaybackQuality playback_quality = 32; + repeated string signals = 33; + string session_command_id = 35; +} + +message ProvidedTrack { + reserved 11; + + string uri = 1; + string uid = 2; + map metadata = 3; + repeated string removed = 4; + repeated string blocked = 5; + string provider = 6; + Restrictions restrictions = 7; + string album_uri = 8; + repeated string disallow_reasons = 9; + string artist_uri = 10; +} + +message ContextIndex { + uint32 page = 1; + uint32 track = 2; +} + +message ModeRestrictions { + map values = 1; +} + +message RestrictionReasons { + repeated string reasons = 1; +} + +message Restrictions { + reserved 26; + reserved 27; + + repeated string disallow_pausing_reasons = 1; + repeated string disallow_resuming_reasons = 2; + repeated string disallow_seeking_reasons = 3; + repeated string disallow_peeking_prev_reasons = 4; + repeated string disallow_peeking_next_reasons = 5; + repeated string disallow_skipping_prev_reasons = 6; + repeated string disallow_skipping_next_reasons = 7; + repeated string disallow_toggling_repeat_context_reasons = 8; + repeated string disallow_toggling_repeat_track_reasons = 9; + repeated string disallow_toggling_shuffle_reasons = 10; + repeated string disallow_set_queue_reasons = 11; + repeated string disallow_interrupting_playback_reasons = 12; + repeated string disallow_transferring_playback_reasons = 13; + repeated string disallow_remote_control_reasons = 14; + repeated string disallow_inserting_into_next_tracks_reasons = 15; + repeated string disallow_inserting_into_context_tracks_reasons = 16; + repeated string disallow_reordering_in_next_tracks_reasons = 17; + repeated string disallow_reordering_in_context_tracks_reasons = 18; + repeated string disallow_removing_from_next_tracks_reasons = 19; + repeated string disallow_removing_from_context_tracks_reasons = 20; + repeated string disallow_updating_context_reasons = 21; + repeated string disallow_playing_reasons = 22; + repeated string disallow_stopping_reasons = 23; + repeated string disallow_add_to_queue_reasons = 24; + repeated string disallow_setting_playback_speed_reasons = 25; + map disallow_setting_modes = 28; + map disallow_signals = 29; +} + +message PlayOrigin { + string feature_identifier = 1; + string feature_version = 2; + string view_uri = 3; + string external_referrer = 4; + string referrer_identifier = 5; + string device_identifier = 6; + repeated string feature_classes = 7; + string restriction_identifier = 8; +} + +message ContextPlayerOptions { + bool shuffling_context = 1; + bool repeating_context = 2; + bool repeating_track = 3; + map modes = 5; + optional float playback_speed = 4; +} + +message Suppressions { + repeated string providers = 1; +} + +message PlaybackQuality { + BitrateLevel bitrate_level = 1; + BitrateStrategy strategy = 2; + BitrateLevel target_bitrate_level = 3; + bool target_bitrate_available = 4; + HiFiStatus hifi_status = 5; +} + +enum BitrateLevel { + unknown_bitrate_level = 0; + low = 1; + normal = 2; + high = 3; + very_high = 4; + hifi = 5; + hifi24 = 6; +} + +enum BitrateStrategy { + unknown_strategy = 0; + best_matching = 1; + backend_advised = 2; + offlined_file = 3; + cached_file = 4; + local_file = 5; +} + +enum HiFiStatus { + none = 0; + off = 1; + on = 2; +} diff --git a/protocol/proto/player_license.proto b/protocol/proto/player_license.proto new file mode 100644 index 00000000..106bb356 --- /dev/null +++ b/protocol/proto/player_license.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message PlayerLicense { + optional string identifier = 1; +} diff --git a/protocol/proto/player_model.proto b/protocol/proto/player_model.proto new file mode 100644 index 00000000..6856ca0d --- /dev/null +++ b/protocol/proto/player_model.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "logging_params.proto"; + +option optimize_for = CODE_SIZE; + +message PlayerModel { + optional bool is_paused = 1; + optional uint64 hash = 2; + optional LoggingParams logging_params = 3; + + optional StartReason start_reason = 4; + enum StartReason { + REMOTE_TRANSFER = 0; + COMEBACK = 1; + PLAY_CONTEXT = 2; + PLAY_SPECIFIC_TRACK = 3; + TRACK_FINISHED = 4; + SKIP_TO_NEXT_TRACK = 5; + SKIP_TO_PREV_TRACK = 6; + ERROR = 7; + IGNORED = 8; + UNKNOWN = 9; + } +} diff --git a/protocol/proto/playlist4_external.proto b/protocol/proto/playlist4_external.proto new file mode 100644 index 00000000..1e4f4e7c --- /dev/null +++ b/protocol/proto/playlist4_external.proto @@ -0,0 +1,365 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist4.proto; + +import "lens-model.proto"; +import "playlist_permission.proto"; +import "signal-model.proto"; + +option optimize_for = CODE_SIZE; +option java_outer_classname = "Playlist4ApiProto"; +option java_package = "com.spotify.playlist4.proto"; + +message Item { + required string uri = 1; + optional ItemAttributes attributes = 2; +} + +message MetaItem { + optional bytes revision = 1; + optional ListAttributes attributes = 2; + optional int32 length = 3; + optional int64 timestamp = 4; + optional string owner_username = 5; + optional bool abuse_reporting_enabled = 6; + optional playlist_permission.proto.Capabilities capabilities = 7; + repeated GeoblockBlockingType geoblock = 8; + optional sint32 status_code = 9; +} + +message ListItems { + required int32 pos = 1; + required bool truncated = 2; + repeated Item items = 3; + repeated MetaItem meta_items = 4; + repeated playlist.signal.proto.Signal available_signals = 5; + optional string continuation_token = 6; +} + +message PaginatedUnfollowedListItems { + optional int32 limit = 1; + optional int32 offset = 2; + optional int32 nextPageIndex = 3; + optional int32 previousPageIndex = 4; + optional int32 totalPages = 5; + repeated UnfollowedListItem items = 6; +} + +message UnfollowedListItem { + optional string uri = 1; + optional bool recoverable = 2; + optional string name = 3; + optional int64 deleted_at = 4; + optional int32 length = 5; +} + +message FormatListAttribute { + optional string key = 1; + optional string value = 2; +} + +message PictureSize { + optional string target_name = 1; + optional string url = 2; +} + +message RecommendationInfo { + optional bool is_recommendation = 1; +} + +message ListAttributes { + optional string name = 1; + optional string description = 2; + optional bytes picture = 3; + optional bool collaborative = 4; + optional string pl3_version = 5; + optional bool deleted_by_owner = 6; + optional string client_id = 10; + optional string format = 11; + repeated FormatListAttribute format_attributes = 12; + repeated PictureSize picture_size = 13; + optional bytes sequence_context_template = 14; + optional bytes ai_curation_reference_id = 15; +} + +message ItemAttributes { + optional string added_by = 1; + optional int64 timestamp = 2; + optional int64 seen_at = 9; + optional bool public = 10; + repeated FormatListAttribute format_attributes = 11; + optional bytes item_id = 12; + optional lens.model.proto.Lens source_lens = 13; + repeated playlist.signal.proto.Signal available_signals = 14; + optional RecommendationInfo recommendation_info = 15; + optional bytes sequence_child_template = 16; +} + +message Add { + optional int32 from_index = 1; + repeated Item items = 2; + optional bool add_last = 4; + optional bool add_first = 5; + optional Item add_before_item = 6; + optional Item add_after_item = 7; +} + +message Rem { + optional int32 from_index = 1; + optional int32 length = 2; + repeated Item items = 3; + optional bool items_as_key = 7; +} + +message Mov { + optional int32 from_index = 1; + optional int32 length = 2; + optional int32 to_index = 3; + repeated Item items = 4; + optional Item add_before_item = 5; + optional Item add_after_item = 6; + optional bool add_first = 7; + optional bool add_last = 8; +} + +message ItemAttributesPartialState { + required ItemAttributes values = 1; + repeated ItemAttributeKind no_value = 2; +} + +message ListAttributesPartialState { + required ListAttributes values = 1; + repeated ListAttributeKind no_value = 2; +} + +message UpdateItemAttributes { + optional int32 index = 1; + required ItemAttributesPartialState new_attributes = 2; + optional ItemAttributesPartialState old_attributes = 3; + optional Item item = 4; +} + +message UpdateListAttributes { + required ListAttributesPartialState new_attributes = 1; + optional ListAttributesPartialState old_attributes = 2; +} + +message UpdateItemUris { + repeated UriReplacement uri_replacements = 1; +} + +message UriReplacement { + optional int32 index = 1; + optional Item item = 2; + optional string new_uri = 3; +} + +message Op { + enum Kind { + KIND_UNKNOWN = 0; + ADD = 2; + REM = 3; + MOV = 4; + UPDATE_ITEM_ATTRIBUTES = 5; + UPDATE_LIST_ATTRIBUTES = 6; + UPDATE_ITEM_URIS = 7; + } + + required Kind kind = 1; + optional Add add = 2; + optional Rem rem = 3; + optional Mov mov = 4; + optional UpdateItemAttributes update_item_attributes = 5; + optional UpdateListAttributes update_list_attributes = 6; + optional UpdateItemUris update_item_uris = 7; +} + +message OpList { + repeated Op ops = 1; +} + +message ChangeInfo { + optional string user = 1; + optional int64 timestamp = 2; + optional bool admin = 3; + optional bool undo = 4; + optional bool redo = 5; + optional bool merge = 6; + optional bool compressed = 7; + optional bool migration = 8; + optional int32 split_id = 9; + optional SourceInfo source = 10; +} + +message SourceInfo { + enum Client { + CLIENT_UNKNOWN = 0; + NATIVE_HERMES = 1; + CLIENT = 2; + PYTHON = 3; + JAVA = 4; + WEBPLAYER = 5; + LIBSPOTIFY = 6; + } + + optional Client client = 1; + optional string app = 3; + optional string source = 4; + optional string version = 5; + optional string server_domain = 6; +} + +message Delta { + optional bytes base_version = 1; + repeated Op ops = 2; + optional ChangeInfo info = 4; +} + +message Diff { + required bytes from_revision = 1; + repeated Op ops = 2; + required bytes to_revision = 3; +} + +message ListChanges { + optional bytes base_revision = 1; + repeated Delta deltas = 2; + optional bool want_resulting_revisions = 3; + optional bool want_sync_result = 4; + repeated int64 nonces = 6; +} + +message ListSignals { + optional bytes base_revision = 1; + repeated playlist.signal.proto.Signal emitted_signals = 2; +} + +message SelectedListContent { + optional bytes revision = 1; + optional int32 length = 2; + optional ListAttributes attributes = 3; + optional ListItems contents = 5; + optional Diff diff = 6; + optional Diff sync_result = 7; + repeated bytes resulting_revisions = 8; + optional bool multiple_heads = 9; + optional bool up_to_date = 10; + repeated int64 nonces = 14; + optional int64 timestamp = 15; + optional string owner_username = 16; + optional bool abuse_reporting_enabled = 17; + optional spotify.playlist_permission.proto.Capabilities capabilities = 18; + repeated GeoblockBlockingType geoblock = 19; + optional bool changes_require_resync = 20; + optional int64 created_at = 21; + optional AppliedLenses applied_lenses = 22; +} + +message AppliedLenses { + repeated lens.model.proto.LensState states = 1; +} + +message CreateListReply { + required string uri = 1; + optional bytes revision = 2; +} + +message PlaylistV1UriRequest { + repeated string v2_uris = 1; +} + +message PlaylistV1UriReply { + map v2_uri_to_v1_uri = 1; +} + +message ListUpdateRequest { + optional bytes base_revision = 1; + optional ListAttributes attributes = 2; + repeated Item items = 3; + optional ChangeInfo info = 4; +} + +message RegisterPlaylistImageRequest { + optional string upload_token = 1; +} + +message RegisterPlaylistImageResponse { + optional bytes picture = 1; +} + +message ResolvedPersonalizedPlaylist { + optional string uri = 1; + optional string tag = 2; +} + +message PlaylistUriResolverResponse { + repeated ResolvedPersonalizedPlaylist resolved_playlists = 1; +} + +message SubscribeRequest { + repeated bytes uris = 1; +} + +message UnsubscribeRequest { + repeated bytes uris = 1; +} + +message PlaylistModificationInfo { + optional bytes uri = 1; + optional bytes new_revision = 2; + optional bytes parent_revision = 3; + repeated Op ops = 4; +} + +message RootlistModificationInfo { + optional bytes new_revision = 1; + optional bytes parent_revision = 2; + repeated Op ops = 3; +} + +message FollowerUpdate { + optional string uri = 1; + optional string username = 2; + optional bool is_following = 3; + optional uint64 timestamp = 4; +} + +enum ListAttributeKind { + LIST_UNKNOWN = 0; + LIST_NAME = 1; + LIST_DESCRIPTION = 2; + LIST_PICTURE = 3; + LIST_COLLABORATIVE = 4; + LIST_PL3_VERSION = 5; + LIST_DELETED_BY_OWNER = 6; + LIST_CLIENT_ID = 10; + LIST_FORMAT = 11; + LIST_FORMAT_ATTRIBUTES = 12; + LIST_PICTURE_SIZE = 13; + LIST_SEQUENCE_CONTEXT_TEMPLATE = 14; + LIST_AI_CURATION_REFERENCE_ID = 15; +} + +enum ItemAttributeKind { + ITEM_UNKNOWN = 0; + ITEM_ADDED_BY = 1; + ITEM_TIMESTAMP = 2; + ITEM_SEEN_AT = 9; + ITEM_PUBLIC = 10; + ITEM_FORMAT_ATTRIBUTES = 11; + ITEM_ID = 12; + ITEM_SOURCE_LENS = 13; + ITEM_AVAILABLE_SIGNALS = 14; + ITEM_RECOMMENDATION_INFO = 15; + ITEM_SEQUENCE_CHILD_TEMPLATE = 16; +} + +enum GeoblockBlockingType { + GEOBLOCK_BLOCKING_TYPE_UNSPECIFIED = 0; + GEOBLOCK_BLOCKING_TYPE_TITLE = 1; + GEOBLOCK_BLOCKING_TYPE_DESCRIPTION = 2; + GEOBLOCK_BLOCKING_TYPE_IMAGE = 3; +} + diff --git a/protocol/proto/playlist4changes.proto b/protocol/proto/playlist4changes.proto deleted file mode 100644 index 6b424b71..00000000 --- a/protocol/proto/playlist4changes.proto +++ /dev/null @@ -1,87 +0,0 @@ -syntax = "proto2"; - -import "playlist4ops.proto"; -import "playlist4meta.proto"; -import "playlist4content.proto"; -import "playlist4issues.proto"; - -message ChangeInfo { - optional string user = 0x1; - optional int32 timestamp = 0x2; - optional bool admin = 0x3; - optional bool undo = 0x4; - optional bool redo = 0x5; - optional bool merge = 0x6; - optional bool compressed = 0x7; - optional bool migration = 0x8; -} - -message Delta { - optional bytes base_version = 0x1; - repeated Op ops = 0x2; - optional ChangeInfo info = 0x4; -} - -message Merge { - optional bytes base_version = 0x1; - optional bytes merge_version = 0x2; - optional ChangeInfo info = 0x4; -} - -message ChangeSet { - optional Kind kind = 0x1; - enum Kind { - KIND_UNKNOWN = 0x0; - DELTA = 0x2; - MERGE = 0x3; - } - optional Delta delta = 0x2; - optional Merge merge = 0x3; -} - -message RevisionTaggedChangeSet { - optional bytes revision = 0x1; - optional ChangeSet change_set = 0x2; -} - -message Diff { - optional bytes from_revision = 0x1; - repeated Op ops = 0x2; - optional bytes to_revision = 0x3; -} - -message ListDump { - optional bytes latestRevision = 0x1; - optional int32 length = 0x2; - optional ListAttributes attributes = 0x3; - optional ListChecksum checksum = 0x4; - optional ListItems contents = 0x5; - repeated Delta pendingDeltas = 0x7; -} - -message ListChanges { - optional bytes baseRevision = 0x1; - repeated Delta deltas = 0x2; - optional bool wantResultingRevisions = 0x3; - optional bool wantSyncResult = 0x4; - optional ListDump dump = 0x5; - repeated int32 nonces = 0x6; -} - -message SelectedListContent { - optional bytes revision = 0x1; - optional int32 length = 0x2; - optional ListAttributes attributes = 0x3; - optional ListChecksum checksum = 0x4; - optional ListItems contents = 0x5; - optional Diff diff = 0x6; - optional Diff syncResult = 0x7; - repeated bytes resultingRevisions = 0x8; - optional bool multipleHeads = 0x9; - optional bool upToDate = 0xa; - repeated ClientResolveAction resolveAction = 0xc; - repeated ClientIssue issues = 0xd; - repeated int32 nonces = 0xe; - optional string owner_username =0x10; -} - diff --git a/protocol/proto/playlist4content.proto b/protocol/proto/playlist4content.proto deleted file mode 100644 index 50d197fa..00000000 --- a/protocol/proto/playlist4content.proto +++ /dev/null @@ -1,37 +0,0 @@ -syntax = "proto2"; - -import "playlist4meta.proto"; -import "playlist4issues.proto"; - -message Item { - optional string uri = 0x1; - optional ItemAttributes attributes = 0x2; -} - -message ListItems { - optional int32 pos = 0x1; - optional bool truncated = 0x2; - repeated Item items = 0x3; -} - -message ContentRange { - optional int32 pos = 0x1; - optional int32 length = 0x2; -} - -message ListContentSelection { - optional bool wantRevision = 0x1; - optional bool wantLength = 0x2; - optional bool wantAttributes = 0x3; - optional bool wantChecksum = 0x4; - optional bool wantContent = 0x5; - optional ContentRange contentRange = 0x6; - optional bool wantDiff = 0x7; - optional bytes baseRevision = 0x8; - optional bytes hintRevision = 0x9; - optional bool wantNothingIfUpToDate = 0xa; - optional bool wantResolveAction = 0xc; - repeated ClientIssue issues = 0xd; - repeated ClientResolveAction resolveAction = 0xe; -} - diff --git a/protocol/proto/playlist4issues.proto b/protocol/proto/playlist4issues.proto deleted file mode 100644 index 3808d532..00000000 --- a/protocol/proto/playlist4issues.proto +++ /dev/null @@ -1,43 +0,0 @@ -syntax = "proto2"; - -message ClientIssue { - optional Level level = 0x1; - enum Level { - LEVEL_UNKNOWN = 0x0; - LEVEL_DEBUG = 0x1; - LEVEL_INFO = 0x2; - LEVEL_NOTICE = 0x3; - LEVEL_WARNING = 0x4; - LEVEL_ERROR = 0x5; - } - optional Code code = 0x2; - enum Code { - CODE_UNKNOWN = 0x0; - CODE_INDEX_OUT_OF_BOUNDS = 0x1; - CODE_VERSION_MISMATCH = 0x2; - CODE_CACHED_CHANGE = 0x3; - CODE_OFFLINE_CHANGE = 0x4; - CODE_CONCURRENT_CHANGE = 0x5; - } - optional int32 repeatCount = 0x3; -} - -message ClientResolveAction { - optional Code code = 0x1; - enum Code { - CODE_UNKNOWN = 0x0; - CODE_NO_ACTION = 0x1; - CODE_RETRY = 0x2; - CODE_RELOAD = 0x3; - CODE_DISCARD_LOCAL_CHANGES = 0x4; - CODE_SEND_DUMP = 0x5; - CODE_DISPLAY_ERROR_MESSAGE = 0x6; - } - optional Initiator initiator = 0x2; - enum Initiator { - INITIATOR_UNKNOWN = 0x0; - INITIATOR_SERVER = 0x1; - INITIATOR_CLIENT = 0x2; - } -} - diff --git a/protocol/proto/playlist4meta.proto b/protocol/proto/playlist4meta.proto deleted file mode 100644 index 4c22a9f0..00000000 --- a/protocol/proto/playlist4meta.proto +++ /dev/null @@ -1,52 +0,0 @@ -syntax = "proto2"; - -message ListChecksum { - optional int32 version = 0x1; - optional bytes sha1 = 0x4; -} - -message DownloadFormat { - optional Codec codec = 0x1; - enum Codec { - CODEC_UNKNOWN = 0x0; - OGG_VORBIS = 0x1; - FLAC = 0x2; - MPEG_1_LAYER_3 = 0x3; - } -} - -message ListAttributes { - optional string name = 0x1; - optional string description = 0x2; - optional bytes picture = 0x3; - optional bool collaborative = 0x4; - optional string pl3_version = 0x5; - optional bool deleted_by_owner = 0x6; - optional bool restricted_collaborative = 0x7; - optional int64 deprecated_client_id = 0x8; - optional bool public_starred = 0x9; - optional string client_id = 0xa; -} - -message ItemAttributes { - optional string added_by = 0x1; - optional int64 timestamp = 0x2; - optional string message = 0x3; - optional bool seen = 0x4; - optional int64 download_count = 0x5; - optional DownloadFormat download_format = 0x6; - optional string sevendigital_id = 0x7; - optional int64 sevendigital_left = 0x8; - optional int64 seen_at = 0x9; - optional bool public = 0xa; -} - -message StringAttribute { - optional string key = 0x1; - optional string value = 0x2; -} - -message StringAttributes { - repeated StringAttribute attribute = 0x1; -} - diff --git a/protocol/proto/playlist4ops.proto b/protocol/proto/playlist4ops.proto deleted file mode 100644 index dbbfcaa9..00000000 --- a/protocol/proto/playlist4ops.proto +++ /dev/null @@ -1,103 +0,0 @@ -syntax = "proto2"; - -import "playlist4meta.proto"; -import "playlist4content.proto"; - -message Add { - optional int32 fromIndex = 0x1; - repeated Item items = 0x2; - optional ListChecksum list_checksum = 0x3; - optional bool addLast = 0x4; - optional bool addFirst = 0x5; -} - -message Rem { - optional int32 fromIndex = 0x1; - optional int32 length = 0x2; - repeated Item items = 0x3; - optional ListChecksum list_checksum = 0x4; - optional ListChecksum items_checksum = 0x5; - optional ListChecksum uris_checksum = 0x6; - optional bool itemsAsKey = 0x7; -} - -message Mov { - optional int32 fromIndex = 0x1; - optional int32 length = 0x2; - optional int32 toIndex = 0x3; - optional ListChecksum list_checksum = 0x4; - optional ListChecksum items_checksum = 0x5; - optional ListChecksum uris_checksum = 0x6; -} - -message ItemAttributesPartialState { - optional ItemAttributes values = 0x1; - repeated ItemAttributeKind no_value = 0x2; - - enum ItemAttributeKind { - ITEM_UNKNOWN = 0x0; - ITEM_ADDED_BY = 0x1; - ITEM_TIMESTAMP = 0x2; - ITEM_MESSAGE = 0x3; - ITEM_SEEN = 0x4; - ITEM_DOWNLOAD_COUNT = 0x5; - ITEM_DOWNLOAD_FORMAT = 0x6; - ITEM_SEVENDIGITAL_ID = 0x7; - ITEM_SEVENDIGITAL_LEFT = 0x8; - ITEM_SEEN_AT = 0x9; - ITEM_PUBLIC = 0xa; - } -} - -message ListAttributesPartialState { - optional ListAttributes values = 0x1; - repeated ListAttributeKind no_value = 0x2; - - enum ListAttributeKind { - LIST_UNKNOWN = 0x0; - LIST_NAME = 0x1; - LIST_DESCRIPTION = 0x2; - LIST_PICTURE = 0x3; - LIST_COLLABORATIVE = 0x4; - LIST_PL3_VERSION = 0x5; - LIST_DELETED_BY_OWNER = 0x6; - LIST_RESTRICTED_COLLABORATIVE = 0x7; - } -} - -message UpdateItemAttributes { - optional int32 index = 0x1; - optional ItemAttributesPartialState new_attributes = 0x2; - optional ItemAttributesPartialState old_attributes = 0x3; - optional ListChecksum list_checksum = 0x4; - optional ListChecksum old_attributes_checksum = 0x5; -} - -message UpdateListAttributes { - optional ListAttributesPartialState new_attributes = 0x1; - optional ListAttributesPartialState old_attributes = 0x2; - optional ListChecksum list_checksum = 0x3; - optional ListChecksum old_attributes_checksum = 0x4; -} - -message Op { - optional Kind kind = 0x1; - enum Kind { - KIND_UNKNOWN = 0x0; - ADD = 0x2; - REM = 0x3; - MOV = 0x4; - UPDATE_ITEM_ATTRIBUTES = 0x5; - UPDATE_LIST_ATTRIBUTES = 0x6; - } - optional Add add = 0x2; - optional Rem rem = 0x3; - optional Mov mov = 0x4; - optional UpdateItemAttributes update_item_attributes = 0x5; - optional UpdateListAttributes update_list_attributes = 0x6; -} - -message OpList { - repeated Op ops = 0x1; -} - diff --git a/protocol/proto/playlist_annotate3.proto b/protocol/proto/playlist_annotate3.proto new file mode 100644 index 00000000..3b6b919f --- /dev/null +++ b/protocol/proto/playlist_annotate3.proto @@ -0,0 +1,41 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto2"; + +package spotify_playlist_annotate3.proto; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist_annotate3.proto"; + +message TakedownRequest { + optional AbuseReportState abuse_report_state = 1; +} + +message AnnotateRequest { + optional string description = 1; + optional string image_uri = 2; +} + +message TranscodedPicture { + optional string target_name = 1; + optional string uri = 2; +} + +message PlaylistAnnotation { + optional string description = 1; + optional string picture = 2; + optional RenderFeatures deprecated_render_features = 3 [default = NORMAL_FEATURES, deprecated = true]; + repeated TranscodedPicture transcoded_picture = 4; + optional bool is_abuse_reporting_enabled = 6 [default = true]; + optional AbuseReportState abuse_report_state = 7 [default = OK]; +} + +enum RenderFeatures { + NORMAL_FEATURES = 1; + EXTENDED_FEATURES = 2; +} + +enum AbuseReportState { + OK = 0; + TAKEN_DOWN = 1; +} diff --git a/protocol/proto/playlist_contains_request.proto b/protocol/proto/playlist_contains_request.proto new file mode 100644 index 00000000..c18e4849 --- /dev/null +++ b/protocol/proto/playlist_contains_request.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "contains_request.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistContainsRequest { + string uri = 1; + playlist.cosmos.proto.ContainsRequest request = 2; +} + +message PlaylistContainsResponse { + ResponseStatus status = 1; + playlist.cosmos.proto.ContainsResponse response = 2; +} diff --git a/protocol/proto/playlist_folder_state.proto b/protocol/proto/playlist_folder_state.proto new file mode 100644 index 00000000..c78e3e78 --- /dev/null +++ b/protocol/proto/playlist_folder_state.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message FolderMetadata { + optional string id = 1; + optional string name = 2; + optional uint32 num_folders = 3; + optional uint32 num_playlists = 4; + optional uint32 num_recursive_folders = 5; + optional uint32 num_recursive_playlists = 6; + optional string link = 7; +} diff --git a/protocol/proto/playlist_get_request.proto b/protocol/proto/playlist_get_request.proto new file mode 100644 index 00000000..5ed15ee3 --- /dev/null +++ b/protocol/proto/playlist_get_request.proto @@ -0,0 +1,50 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "google/protobuf/duration.proto"; +import "policy/playlist_request_decoration_policy.proto"; +import "playlist_query.proto"; +import "playlist_request.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistGetRequest { + string uri = 1; + PlaylistQuery query = 2; + playlist.cosmos.proto.PlaylistRequestDecorationPolicy policy = 3; +} + +message PlaylistMultiGetSingleRequest { + string id = 1; + PlaylistGetRequest request = 2; +} + +message PlaylistMultiGetRequest { + repeated PlaylistMultiGetSingleRequest requests = 1; + google.protobuf.Duration timeout = 2; +} + +message PlaylistGetResponse { + ResponseStatus status = 1; + playlist.cosmos.playlist_request.proto.Response data = 2; + PlaylistQuery query = 3; +} + +message PlaylistMultiGetSingleResponse { + string id = 1; + string uri = 2; + PlaylistGetResponse response = 3; +} + +message PlaylistMultiGetResponse { + ResponseStatus status = 1; + repeated PlaylistMultiGetSingleResponse responses = 2; +} + diff --git a/protocol/proto/playlist_members_request.proto b/protocol/proto/playlist_members_request.proto new file mode 100644 index 00000000..d7304257 --- /dev/null +++ b/protocol/proto/playlist_members_request.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "members_request.proto"; +import "members_response.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistMembersResponse { + ResponseStatus status = 1; + playlist.cosmos.proto.PlaylistMembersResponse response = 2; +} diff --git a/protocol/proto/playlist_modification_request.proto b/protocol/proto/playlist_modification_request.proto new file mode 100644 index 00000000..c5fea0b4 --- /dev/null +++ b/protocol/proto/playlist_modification_request.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "modification_request.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistModificationRequest { + string uri = 1; + playlist.cosmos.proto.ModificationRequest request = 2; +} + +message PlaylistModificationResponse { + ResponseStatus status = 1; +} diff --git a/protocol/proto/playlist_offline_request.proto b/protocol/proto/playlist_offline_request.proto new file mode 100644 index 00000000..30c7ebbb --- /dev/null +++ b/protocol/proto/playlist_offline_request.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "playlist_query.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistOfflineRequest { + string uri = 1; + PlaylistQuery query = 2; + PlaylistOfflineAction action = 3; +} + +message PlaylistOfflineResponse { + ResponseStatus status = 1; +} + +enum PlaylistOfflineAction { + NONE = 0; + SET_AS_AVAILABLE_OFFLINE = 1; + REMOVE_AS_AVAILABLE_OFFLINE = 2; +} diff --git a/protocol/proto/playlist_permission.proto b/protocol/proto/playlist_permission.proto new file mode 100644 index 00000000..f155f6e0 --- /dev/null +++ b/protocol/proto/playlist_permission.proto @@ -0,0 +1,179 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist_permission.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message Permission { + optional bytes revision = 1; + optional PermissionLevel permission_level = 2; +} + +message GrantableLevels { + repeated PermissionLevel base = 1; + repeated PermissionLevel member = 2; +} + +message AttributeCapabilities { + optional bool can_edit = 1; +} + +message ListAttributeCapabilities { + optional AttributeCapabilities name = 1; + optional AttributeCapabilities description = 2; + optional AttributeCapabilities picture = 3; + optional AttributeCapabilities collaborative = 4; + optional AttributeCapabilities deleted_by_owner = 6; + optional AttributeCapabilities ai_curation_reference_id = 15; +} + +message Capabilities { + optional bool can_view = 1; + optional bool can_administrate_permissions = 2; + repeated PermissionLevel grantable_level = 3; + optional bool can_edit_metadata = 4; + optional bool can_edit_items = 5; + optional bool can_cancel_membership = 6; + optional GrantableLevels grantable_levels = 7; + optional ListAttributeCapabilities list_attribute_capabilities = 8; +} + +message CapabilitiesMultiRequest { + repeated CapabilitiesRequest request = 1; + optional string fallback_username = 2; + optional string fallback_user_id = 3; + optional string fallback_uri = 4; +} + +message CapabilitiesRequestOptions { + optional bool can_view_only = 1; +} + +message CapabilitiesRequest { + optional string username = 1; + optional string user_id = 2; + optional string uri = 3; + optional bool user_is_owner = 4; + optional string permission_grant_token = 5; + optional CapabilitiesRequestOptions request_options = 6; +} + +message CapabilitiesMultiResponse { + repeated CapabilitiesResponse response = 1; +} + +message CapabilitiesResponse { + optional ResponseStatus status = 1; + optional Capabilities capabilities = 2; +} + +message SetPermissionLevelRequest { + optional PermissionLevel permission_level = 1; +} + +message SetPermissionResponse { + optional Permission resulting_permission = 1; +} + +message GetMemberPermissionsResponse { + map member_permissions = 1; +} + +message Permissions { + optional Permission base_permission = 1; +} + +message PermissionState { + optional Permissions permissions = 1; + optional Capabilities capabilities = 2; + optional bool is_private = 3; + optional bool is_collaborative = 4; +} + +message PermissionStatePub { + optional PermissionState permission_state = 1; +} + +message PermissionGrantOptions { + optional Permission permission = 1; + optional int64 ttl_ms = 2; +} + +message PermissionGrant { + optional string token = 1; + optional PermissionGrantOptions permission_grant_options = 2; +} + +message PermissionGrantDetails { + optional bool permission_level_downgraded = 1; +} + +message PermissionGrantDescription { + enum ClaimFailReason { + CLAIM_FAIL_REASON_UNSPECIFIED = 0; + CLAIM_FAIL_REASON_ANONYMOUS = 1; + CLAIM_FAIL_REASON_NO_GRANT_FOUND = 2; + CLAIM_FAIL_REASON_GRANT_EXPIRED = 3; + } + + optional PermissionGrantOptions permission_grant_options = 1; + optional ClaimFailReason claim_fail_reason = 2; + optional bool is_effective = 3; + optional Capabilities capabilities = 4; + repeated PermissionGrantDetails details = 5; +} + +message ClaimPermissionGrantResponse { + optional Permission user_permission = 1; + optional Capabilities capabilities = 2; + repeated PermissionGrantDetails details = 3; +} + +message ResponseStatus { + optional int32 status_code = 1; + optional string status_message = 2; +} + +message PermissionIdentifier { + required PermissionIdentifierKind kind = 1; + optional string user_id = 2; +} + +message PermissionEntry { + optional PermissionIdentifier identifier = 1; + optional Permission permission = 2; +} + +message CreateInitialPermissions { + repeated PermissionEntry permission_entry = 1; +} + +message CreateInitialPermissionsResponse { + repeated PermissionEntry permission_entry = 1; +} + +message DefaultOwnerCapabilitiesResponse { + optional Capabilities capabilities = 1; +} + +enum PermissionLevel { + UNKNOWN = 0; + BLOCKED = 1; + VIEWER = 2; + CONTRIBUTOR = 3; + MADE_FOR = 4; +} + +enum PermissionIdentifierKind { + PERMISSION_IDENTIFIER_KIND_UNSPECIFIED = 0; + PERMISSION_IDENTIFIER_KIND_BASE = 1; + PERMISSION_IDENTIFIER_KIND_MEMBER = 2; + PERMISSION_IDENTIFIER_KIND_ABUSE = 3; + PERMISSION_IDENTIFIER_KIND_PROFILE = 4; + PERMISSION_IDENTIFIER_KIND_AUTHORIZED = 5; +} + diff --git a/protocol/proto/playlist_play_request.proto b/protocol/proto/playlist_play_request.proto new file mode 100644 index 00000000..eb166756 --- /dev/null +++ b/protocol/proto/playlist_play_request.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "es_context.proto"; +import "es_play_options.proto"; +import "es_logging_params.proto"; +import "es_prepare_play_options.proto"; +import "es_play_origin.proto"; +import "playlist_query.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistPlayRequest { + PlaylistQuery playlist_query = 1; + player.esperanto.proto.Context context = 2; + player.esperanto.proto.PlayOptions play_options = 3; + player.esperanto.proto.LoggingParams logging_params = 4; + player.esperanto.proto.PreparePlayOptions prepare_play_options = 5; + player.esperanto.proto.PlayOrigin play_origin = 6; +} + +message PlaylistPlayResponse { + ResponseStatus status = 1; +} diff --git a/protocol/proto/playlist_playback_request.proto b/protocol/proto/playlist_playback_request.proto new file mode 100644 index 00000000..83435a31 --- /dev/null +++ b/protocol/proto/playlist_playback_request.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message PlaybackResponse { + bool success = 1; +} diff --git a/protocol/proto/playlist_playlist_state.proto b/protocol/proto/playlist_playlist_state.proto new file mode 100644 index 00000000..28e78377 --- /dev/null +++ b/protocol/proto/playlist_playlist_state.proto @@ -0,0 +1,54 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "metadata/extension.proto"; +import "metadata/image_group.proto"; +import "playlist_user_state.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message FormatListAttribute { + optional string key = 1; + optional string value = 2; +} + +message Allows { + optional bool can_insert = 1; + optional bool can_remove = 2; +} + +message PlaylistMetadata { + optional string link = 1; + optional string name = 2; + optional User owner = 3; + optional bool owned_by_self = 4; + optional bool collaborative = 5; + optional uint32 total_length = 6; + optional string description = 7; + optional cosmos_util.proto.ImageGroup pictures = 8; + optional bool followed = 9; + optional bool published = 10; + optional bool browsable_offline = 11; + optional bool description_from_annotate = 12; + optional bool picture_from_annotate = 13; + optional string format_list_type = 14; + repeated FormatListAttribute format_list_attributes = 15; + optional bool can_report_annotation_abuse = 16; + optional bool is_loaded = 17; + optional Allows allows = 18; + optional string load_state = 19; + optional User made_for = 20; + repeated cosmos_util.proto.Extension extension = 21; + optional uint32 length_ignoring_text_filter = 22; + optional string ai_curation_reference_id = 23; +} + +message PlaylistOfflineState { + optional string offline = 1; + optional uint32 sync_progress = 2; +} diff --git a/protocol/proto/playlist_query.proto b/protocol/proto/playlist_query.proto new file mode 100644 index 00000000..1aedb0cf --- /dev/null +++ b/protocol/proto/playlist_query.proto @@ -0,0 +1,87 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "policy/supported_link_types_in_playlists.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistRange { + int32 start = 1; + int32 length = 2; +} + +message PlaylistQuery { + repeated BoolPredicate bool_predicates = 1; + enum BoolPredicate { + NO_FILTER = 0; + AVAILABLE = 1; + AVAILABLE_OFFLINE = 2; + ARTIST_NOT_BANNED = 3; + NOT_BANNED = 4; + NOT_EXPLICIT = 5; + NOT_EPISODE = 6; + NOT_RECOMMENDATION = 7; + UNPLAYED = 8; + IN_PROGRESS = 9; + NOT_FULLY_PLAYED = 10; + } + + string text_filter = 2; + + SortBy sort_by = 3; + enum SortBy { + NO_SORT = 0; + ALBUM_ARTIST_NAME_ASC = 1; + ALBUM_ARTIST_NAME_DESC = 2; + TRACK_NUMBER_ASC = 3; + TRACK_NUMBER_DESC = 4; + DISC_NUMBER_ASC = 5; + DISC_NUMBER_DESC = 6; + ALBUM_NAME_ASC = 7; + ALBUM_NAME_DESC = 8; + ARTIST_NAME_ASC = 9; + ARTIST_NAME_DESC = 10; + NAME_ASC = 11; + NAME_DESC = 12; + ADD_TIME_ASC = 13; + ADD_TIME_DESC = 14; + ADDED_BY_ASC = 15; + ADDED_BY_DESC = 16; + DURATION_ASC = 17; + DURATION_DESC = 18; + SHOW_NAME_ASC = 19; + SHOW_NAME_DESC = 20; + PUBLISH_DATE_ASC = 21; + PUBLISH_DATE_DESC = 22; + } + + PlaylistRange range = 4; + int32 update_throttling_ms = 5; + bool group = 6; + PlaylistSourceRestriction source_restriction = 7; + bool show_unavailable = 8; + bool always_show_windowed = 9; + bool load_recommendations = 10; + repeated playlist.cosmos.proto.LinkType supported_placeholder_types = 11; + repeated string descriptor_filter = 12; + string item_id_filter = 13; + + repeated AttributeFilter attribute_filter = 14; + message AttributeFilter { + repeated string contains_one_of = 1; + } + + bool include_all_placeholders = 15; +} + +enum PlaylistSourceRestriction { + NO_RESTRICTION = 0; + RESTRICT_SOURCE_TO_50 = 1; + RESTRICT_SOURCE_TO_500 = 2; +} diff --git a/protocol/proto/playlist_request.proto b/protocol/proto/playlist_request.proto new file mode 100644 index 00000000..5ad7803d --- /dev/null +++ b/protocol/proto/playlist_request.proto @@ -0,0 +1,151 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.playlist_request.proto; + +import "collection/episode_collection_state.proto"; +import "metadata/episode_metadata.proto"; +import "played_state/track_played_state.proto"; +import "played_state/episode_played_state.proto"; +import "sync/episode_sync_state.proto"; +import "metadata/image_group.proto"; +import "on_demand_in_free_reason.proto"; +import "playlist_permission.proto"; +import "playlist_playlist_state.proto"; +import "playlist_track_state.proto"; +import "playlist_user_state.proto"; +import "policy/supported_link_types_in_playlists.proto"; +import "metadata/track_metadata.proto"; +import "metadata/extension.proto"; + +option objc_class_prefix = "SPTPlaylistCosmosPlaylist"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message AvailableSignal { + optional string name = 1; + optional .spotify.playlist.cosmos.playlist_request.proto.SignalState state = 2; +} + +message ItemOfflineState { + optional string offline = 1; + optional uint32 sync_progress = 2; + optional bool locally_playable = 3; +} + +message ItemCollectionState { + optional bool is_in_collection = 1; + optional bool is_banned = 2; +} + +message ItemMetadata { + optional string name = 1; + optional string image = 2; + optional bool is_explicit = 3; +} + +message ItemCurationState { + optional bool is_curated = 1; +} + +message Item { + optional string header_field = 1; + optional uint32 add_time = 2; + optional cosmos.proto.User added_by = 3; + optional cosmos_util.proto.TrackMetadata track_metadata = 4; + optional cosmos.proto.TrackCollectionState track_collection_state = 5; + optional cosmos.proto.TrackOfflineState track_offline_state = 6; + optional string row_id = 7; + optional cosmos_util.proto.TrackPlayState track_play_state = 8; + repeated cosmos.proto.FormatListAttribute format_list_attributes = 9; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 10; + optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 11; + optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 12; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 13; + optional cosmos_util.proto.ImageGroup display_covers = 14; + repeated AvailableSignal available_signals = 15; + optional bool is_recommendation = 16; + repeated cosmos_util.proto.Extension extension = 17; + optional string uri = 18; + optional ItemOfflineState offline_state = 19; + optional ItemCollectionState collection_state = 20; + optional ItemMetadata metadata = 21; + optional ItemCurationState curation_state = 22; + optional bool should_be_obfuscated = 23; +} + +message Lens { + optional string name = 1; +} + +message LensState { + repeated Lens requested_lenses = 1; + repeated Lens applied_lenses = 2; +} + +message Playlist { + optional cosmos.proto.PlaylistMetadata playlist_metadata = 1; + optional cosmos.proto.PlaylistOfflineState playlist_offline_state = 2; + optional LensState lenses = 3; +} + +message RecommendationItem { + optional cosmos_util.proto.TrackMetadata track_metadata = 1; + optional cosmos.proto.TrackCollectionState track_collection_state = 2; + optional cosmos.proto.TrackOfflineState track_offline_state = 3; + optional cosmos_util.proto.TrackPlayState track_play_state = 4; +} + +message Collaborator { + optional cosmos.proto.User user = 1; + optional uint32 number_of_items = 2; + optional uint32 number_of_tracks = 3; + optional uint32 number_of_episodes = 4; + optional bool is_owner = 5; +} + +message Collaborators { + optional uint32 count = 1; + repeated Collaborator collaborator = 2; +} + +message NumberOfItemsForLinkType { + optional cosmos.proto.LinkType link_type = 1; + optional int32 num_items = 2; +} + +message Response { + repeated Item item = 1; + optional Playlist playlist = 2; + optional uint32 unfiltered_length = 3; + optional uint32 unranged_length = 4; + optional uint64 duration = 5; + optional bool loading_contents = 6; + optional uint64 last_modification = 7; + optional uint32 num_followers = 8; + optional bool playable = 9; + repeated RecommendationItem recommendations = 10; + optional bool has_explicit_content = 11; + optional bool contains_spotify_tracks = 12; + optional bool contains_episodes = 13; + optional bool only_contains_explicit = 14; + optional bool contains_audio_episodes = 15; + optional bool contains_tracks = 16; + optional bool is_on_demand_in_free = 17; + optional uint32 number_of_tracks = 18; + optional uint32 number_of_episodes = 19; + optional bool prefer_linear_playback = 20; + optional on_demand_set.proto.OnDemandInFreeReason on_demand_in_free_reason = 21; + optional Collaborators collaborators = 22; + optional playlist_permission.proto.Permission base_permission = 23; + optional playlist_permission.proto.Capabilities user_capabilities = 24; + repeated NumberOfItemsForLinkType number_of_items_per_link_type = 25; + repeated AvailableSignal available_signals = 26; +} + +enum SignalState { + READY = 0; + PENDING = 1; +} + diff --git a/protocol/proto/playlist_set_base_permission_request.proto b/protocol/proto/playlist_set_base_permission_request.proto new file mode 100644 index 00000000..45d7bb5a --- /dev/null +++ b/protocol/proto/playlist_set_base_permission_request.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "playlist_set_permission_request.proto"; +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistSetBasePermissionRequest { + string uri = 1; + playlist.cosmos.proto.SetBasePermissionRequest request = 2; +} + +message PlaylistSetBasePermissionResponse { + ResponseStatus status = 1; + playlist.cosmos.proto.SetBasePermissionResponse response = 2; +} diff --git a/protocol/proto/playlist_set_member_permission_request.proto b/protocol/proto/playlist_set_member_permission_request.proto new file mode 100644 index 00000000..1fe6ba69 --- /dev/null +++ b/protocol/proto/playlist_set_member_permission_request.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +import "response_status.proto"; + +option objc_class_prefix = "ESP"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message PlaylistSetMemberPermissionResponse { + ResponseStatus status = 1; +} diff --git a/protocol/proto/playlist_set_permission_request.proto b/protocol/proto/playlist_set_permission_request.proto new file mode 100644 index 00000000..60e95c76 --- /dev/null +++ b/protocol/proto/playlist_set_permission_request.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "playlist_permission.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message SetBasePermissionRequest { + optional playlist_permission.proto.PermissionLevel permission_level = 1; + optional uint32 timeout_ms = 2; +} + +message SetBasePermissionResponse { + optional playlist_permission.proto.Permission base_permission = 1; +} diff --git a/protocol/proto/playlist_track_state.proto b/protocol/proto/playlist_track_state.proto new file mode 100644 index 00000000..b6fa87ef --- /dev/null +++ b/protocol/proto/playlist_track_state.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option objc_class_prefix = "SPTPlaylist"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message TrackCollectionState { + optional bool is_in_collection = 1; + optional bool can_add_to_collection = 2; + optional bool is_banned = 3; + optional bool can_ban = 4; +} + +message TrackOfflineState { + optional string offline = 1; +} diff --git a/protocol/proto/playlist_user_state.proto b/protocol/proto/playlist_user_state.proto new file mode 100644 index 00000000..4f8565af --- /dev/null +++ b/protocol/proto/playlist_user_state.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message User { + optional string link = 1; + optional string username = 2; + optional string display_name = 3; + optional string image_uri = 4; + optional string thumbnail_uri = 5; + optional int32 color = 6; +} diff --git a/protocol/proto/plugin.proto b/protocol/proto/plugin.proto new file mode 100644 index 00000000..a1f61fff --- /dev/null +++ b/protocol/proto/plugin.proto @@ -0,0 +1,149 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.offline.proto; + +import "google/protobuf/any.proto"; +import "extension_kind.proto"; +import "resource_type.proto"; + +option optimize_for = CODE_SIZE; + +message PluginRegistry { + message Entry { + string id = 1; + repeated LinkType supported_link_types = 2; + ResourceType resource_type = 3; + repeated extendedmetadata.ExtensionKind extension_kinds = 4; + } + + enum LinkType { + EMPTY = 0; + TRACK = 1; + EPISODE = 2; + } + + repeated Entry plugins = 1; +} + +message PluginInit { + string id = 1; +} + +message TargetFormat { + int32 bitrate = 1; +} + +message Metadata { + message Header { + int32 status_code = 1; + bool is_empty = 2; + } + + Header header = 1; + google.protobuf.Any extension_data = 2; +} + +message IdentifyCommand { + message Header { + TargetFormat target_format = 1; + } + + message Query { + message MetadataEntry { + int32 key = 1; + Metadata value = 2; + } + + string link = 1; + repeated MetadataEntry metadata = 2; + } + + Header header = 3; + repeated Query query = 4; +} + +message IdentifyResponse { + message Result { + enum Status { + UNKNOWN = 0; + MISSING = 1; + COMPLETE = 2; + NOT_APPLICABLE = 3; + } + + Status status = 1; + int64 estimated_file_size = 2; + } + + map results = 1; +} + +message DownloadCommand { + message MetadataEntry { + int32 key = 1; + Metadata value = 2; + } + + string link = 1; + TargetFormat target_format = 2; + repeated MetadataEntry metadata = 3; +} + +message DownloadResponse { + enum Error { + OK = 0; + TEMPORARY_ERROR = 1; + PERMANENT_ERROR = 2; + DISK_FULL = 3; + } + + string link = 1; + bool complete = 2; + int64 file_size = 3; + int64 bytes_downloaded = 4; + Error error = 5; +} + +message StopDownloadCommand { + string link = 1; +} + +message StopDownloadResponse { +} + +message RemoveCommand { + message Header { + } + + message Query { + string link = 1; + } + + Header header = 2; + repeated Query query = 3; +} + +message RemoveResponse { +} + +message PluginCommand { + string id = 1; + oneof command { + IdentifyCommand identify = 2; + DownloadCommand download = 3; + RemoveCommand remove = 4; + StopDownloadCommand stop_download = 5; + } +} + +message PluginResponse { + string id = 1; + oneof response { + IdentifyResponse identify = 2; + DownloadResponse download = 3; + RemoveResponse remove = 4; + StopDownloadResponse stop_download = 5; + } +} diff --git a/protocol/proto/podcast_ad_segments.proto b/protocol/proto/podcast_ad_segments.proto new file mode 100644 index 00000000..d24b697b --- /dev/null +++ b/protocol/proto/podcast_ad_segments.proto @@ -0,0 +1,35 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.ads.formats; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastAdsProto"; +option java_package = "com.spotify.ads.formats.proto"; + +message PodcastAds { + repeated string file_ids = 1; + repeated string manifest_ids = 2; + repeated Segment segments = 3; + string request_id = 4; +} + +message Segment { + Slot slot = 1; + int32 start_ms = 2; + int32 stop_ms = 3; +} + +enum Slot { + UNKNOWN = 0; + PODCAST_PREROLL = 1; + PODCAST_POSTROLL = 2; + PODCAST_MIDROLL_1 = 3; + PODCAST_MIDROLL_2 = 4; + PODCAST_MIDROLL_3 = 5; + PODCAST_MIDROLL_4 = 6; + PODCAST_MIDROLL_5 = 7; +} diff --git a/protocol/proto/podcast_cta_cards.proto b/protocol/proto/podcast_cta_cards.proto new file mode 100644 index 00000000..a516fb40 --- /dev/null +++ b/protocol/proto/podcast_cta_cards.proto @@ -0,0 +1,9 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.context_mdata.podcastctacards; + +message Card { + bool has_cards = 1; +} diff --git a/protocol/proto/podcast_paywalls_cosmos.proto b/protocol/proto/podcast_paywalls_cosmos.proto new file mode 100644 index 00000000..9b818137 --- /dev/null +++ b/protocol/proto/podcast_paywalls_cosmos.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_paywalls_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message PodcastPaywallsShowSubscriptionRequest { + string show_uri = 1; +} + +message PodcastPaywallsShowSubscriptionResponse { + bool is_user_subscribed = 1; +} diff --git a/protocol/proto/podcast_poll.proto b/protocol/proto/podcast_poll.proto new file mode 100644 index 00000000..7374c175 --- /dev/null +++ b/protocol/proto/podcast_poll.proto @@ -0,0 +1,48 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.polls; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PollMetadataProto"; +option java_package = "com.spotify.podcastcreatorinteractivity.v1"; + +message PodcastPoll { + Poll poll = 1; +} + +message Poll { + int32 id = 1; + string opening_date = 2; + string closing_date = 3; + int32 entity_timestamp_ms = 4; + string entity_uri = 5; + string name = 6; + string question = 7; + PollType type = 8; + repeated PollOption options = 9; + PollStatus status = 10; +} + +message PollOption { + string option = 1; + int32 total_votes = 2; + int32 poll_id = 3; + int32 option_id = 4; +} + +enum PollType { + MULTIPLE_CHOICE = 0; + SINGLE_CHOICE = 1; +} + +enum PollStatus { + DRAFT = 0; + SCHEDULED = 1; + LIVE = 2; + CLOSED = 3; + BLOCKED = 4; +} diff --git a/protocol/proto/podcast_qna.proto b/protocol/proto/podcast_qna.proto new file mode 100644 index 00000000..340d21f0 --- /dev/null +++ b/protocol/proto/podcast_qna.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.qanda; + +import "google/protobuf/timestamp.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "QnAMetadataProto"; +option java_package = "com.spotify.podcastcreatorinteractivity.v1"; + +message PodcastQna { + Prompt prompt = 1; +} + +message Prompt { + int32 id = 1; + google.protobuf.Timestamp opening_date = 2; + google.protobuf.Timestamp closing_date = 3; + string text = 4; + QAndAStatus status = 5; +} + +enum QAndAStatus { + DRAFT = 0; + SCHEDULED = 1; + LIVE = 2; + CLOSED = 3; + DELETED = 4; +} diff --git a/protocol/proto/podcast_ratings.proto b/protocol/proto/podcast_ratings.proto new file mode 100644 index 00000000..c78c0282 --- /dev/null +++ b/protocol/proto/podcast_ratings.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.ratings; + +import "google/protobuf/timestamp.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "RatingsMetadataProto"; +option java_package = "com.spotify.podcastcreatorinteractivity.v1"; + +message Rating { + string user_id = 1; + string show_uri = 2; + int32 rating = 3; + google.protobuf.Timestamp rated_at = 4; +} + +message AverageRating { + double average = 1; + int64 total_ratings = 2; + bool show_average = 3; +} + +message PodcastRating { + AverageRating average_rating = 1; + Rating rating = 2; + bool can_rate = 3; +} diff --git a/protocol/proto/podcast_segments.proto b/protocol/proto/podcast_segments.proto new file mode 100644 index 00000000..52a075f3 --- /dev/null +++ b/protocol/proto/podcast_segments.proto @@ -0,0 +1,47 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastSegmentsProto"; +option java_package = "com.spotify.podcastsegments.proto"; + +message PodcastSegments { + string episode_uri = 1; + repeated PlaybackSegment playback_segments = 2; + repeated EmbeddedSegment embedded_segments = 3; + bool can_upsell = 4; + string album_mosaic_uri = 5; + repeated string artists = 6; + int32 duration_ms = 7; +} + +message PlaybackSegment { + string uri = 1; + int32 start_ms = 2; + int32 stop_ms = 3; + int32 duration_ms = 4; + SegmentType type = 5; + string title = 6; + string subtitle = 7; + string image_url = 8; + string action_url = 9; + bool is_abridged = 10; +} + +message EmbeddedSegment { + string uri = 1; + int32 absolute_start_ms = 2; + int32 absolute_stop_ms = 3; +} + +enum SegmentType { + UNKNOWN = 0; + TALK = 1; + MUSIC = 2; + UPSELL = 3; +} diff --git a/protocol/proto/podcast_segments_cosmos_request.proto b/protocol/proto/podcast_segments_cosmos_request.proto new file mode 100644 index 00000000..1d5a51f4 --- /dev/null +++ b/protocol/proto/podcast_segments_cosmos_request.proto @@ -0,0 +1,37 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments.cosmos.proto; + +import "policy/album_decoration_policy.proto"; +import "policy/artist_decoration_policy.proto"; +import "policy/episode_decoration_policy.proto"; +import "policy/track_decoration_policy.proto"; +import "policy/show_decoration_policy.proto"; + +option optimize_for = CODE_SIZE; + +message SegmentsRequest { + repeated string episode_uris = 1; + TrackDecorationPolicy track_decoration_policy = 2; + SegmentsPolicy segments_policy = 3; + EpisodeDecorationPolicy episode_decoration_policy = 4; +} + +message TrackDecorationPolicy { + cosmos_util.proto.TrackDecorationPolicy track_policy = 1; + cosmos_util.proto.ArtistDecorationPolicy artists_policy = 2; + cosmos_util.proto.AlbumDecorationPolicy album_policy = 3; + cosmos_util.proto.ArtistDecorationPolicy album_artist_policy = 4; +} + +message SegmentsPolicy { + bool playback = 1; + bool embedded = 2; +} + +message EpisodeDecorationPolicy { + cosmos_util.proto.EpisodeDecorationPolicy episode_policy = 1; + cosmos_util.proto.ShowDecorationPolicy show_decoration_policy = 2; +} diff --git a/protocol/proto/podcast_segments_cosmos_response.proto b/protocol/proto/podcast_segments_cosmos_response.proto new file mode 100644 index 00000000..a80f7270 --- /dev/null +++ b/protocol/proto/podcast_segments_cosmos_response.proto @@ -0,0 +1,39 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcast_segments.cosmos.proto; + +import "metadata/episode_metadata.proto"; +import "podcast_segments.proto"; +import "metadata/track_metadata.proto"; + +option optimize_for = CODE_SIZE; + +message SegmentsResponse { + bool success = 1; + repeated EpisodeSegments episode_segments = 2; +} + +message EpisodeSegments { + string episode_uri = 1; + repeated DecoratedSegment segments = 2; + bool can_upsell = 3; + string album_mosaic_uri = 4; + repeated string artists = 5; + int32 duration_ms = 6; +} + +message DecoratedSegment { + string uri = 1; + int32 start_ms = 2; + int32 stop_ms = 3; + cosmos_util.proto.TrackMetadata track_metadata = 4; + SegmentType type = 5; + string title = 6; + string subtitle = 7; + string image_url = 8; + string action_url = 9; + cosmos_util.proto.EpisodeMetadata episode_metadata = 10; + bool is_abridged = 11; +} diff --git a/protocol/proto/podcast_subscription.proto b/protocol/proto/podcast_subscription.proto new file mode 100644 index 00000000..36107386 --- /dev/null +++ b/protocol/proto/podcast_subscription.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.podcast_paywalls; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastSubscriptionProto"; +option java_package = "com.spotify.podcastsubscription.proto"; + +message PodcastSubscription { + bool is_paywalled = 1; + bool is_user_subscribed = 2; + + UserExplanation user_explanation = 3; + enum UserExplanation { + SUBSCRIPTION_DIALOG = 0; + NONE = 1; + } +} diff --git a/protocol/proto/podcast_virality.proto b/protocol/proto/podcast_virality.proto new file mode 100644 index 00000000..902dca90 --- /dev/null +++ b/protocol/proto/podcast_virality.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.podcastvirality.v1; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastViralityProto"; +option java_package = "com.spotify.podcastvirality.proto.v1"; + +message PodcastVirality { + bool is_viral = 1; +} diff --git a/protocol/proto/podcastextensions.proto b/protocol/proto/podcastextensions.proto new file mode 100644 index 00000000..7a5c9363 --- /dev/null +++ b/protocol/proto/podcastextensions.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.podcast.extensions; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "PodcastExtensionsProto"; +option java_package = "com.spotify.podcastextensions.proto"; + +message PodcastTopics { + repeated PodcastTopic topics = 1; +} + +message PodcastTopic { + string uri = 1; + string title = 2; +} + +message PodcastHtmlDescription { + Header header = 1; + message Header { + } + + string html_description = 2; +} diff --git a/protocol/proto/policy/album_decoration_policy.proto b/protocol/proto/policy/album_decoration_policy.proto new file mode 100644 index 00000000..11a38c63 --- /dev/null +++ b/protocol/proto/policy/album_decoration_policy.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message AlbumDecorationPolicy { + bool link = 1; + bool name = 2; + bool copyrights = 3; + bool covers = 4; + bool year = 5; + bool num_discs = 6; + bool num_tracks = 7; + bool playability = 8; + bool is_premium_only = 9; +} + +message AlbumCollectionDecorationPolicy { + bool collection_link = 1; + bool num_tracks_in_collection = 2; + bool complete = 3; +} + +message AlbumSyncDecorationPolicy { + bool inferred_offline = 1; + bool offline_state = 2; + bool sync_progress = 3; +} diff --git a/protocol/proto/policy/artist_decoration_policy.proto b/protocol/proto/policy/artist_decoration_policy.proto new file mode 100644 index 00000000..1e60743b --- /dev/null +++ b/protocol/proto/policy/artist_decoration_policy.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message ArtistDecorationPolicy { + bool link = 1; + bool name = 2; + bool is_various_artists = 3; + bool portraits = 4; +} + +message ArtistCollectionDecorationPolicy { + bool collection_link = 1; + bool is_followed = 2; + bool num_tracks_in_collection = 3; + bool num_albums_in_collection = 4; + bool is_banned = 5; + bool can_ban = 6; + bool num_explicitly_liked_tracks = 8; +} + +message ArtistSyncDecorationPolicy { + bool inferred_offline = 1; + bool offline_state = 2; + bool sync_progress = 3; +} diff --git a/protocol/proto/policy/episode_decoration_policy.proto b/protocol/proto/policy/episode_decoration_policy.proto new file mode 100644 index 00000000..a194ddaf --- /dev/null +++ b/protocol/proto/policy/episode_decoration_policy.proto @@ -0,0 +1,59 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +import "extension_kind.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message EpisodeDecorationPolicy { + reserved 19; + reserved 20; + bool link = 1; + bool length = 2; + bool name = 3; + bool manifest_id = 4; + bool preview_id = 5; + bool preview_manifest_id = 6; + bool description = 7; + bool publish_date = 8; + bool covers = 9; + bool freeze_frames = 10; + bool language = 11; + bool available = 12; + bool media_type_enum = 13; + bool number = 14; + bool backgroundable = 15; + bool is_explicit = 16; + bool type = 17; + bool is_music_and_talk = 18; + repeated extendedmetadata.ExtensionKind extension = 21; + bool is_19_plus_only = 22; + bool is_book_chapter = 23; + bool is_podcast_short = 24; + bool is_curated = 25; +} + +message EpisodeCollectionDecorationPolicy { + bool is_following_show = 1; + bool is_in_listen_later = 2; + bool is_new = 3; +} + +message EpisodeSyncDecorationPolicy { + bool offline = 1; + bool sync_progress = 2; +} + +message EpisodePlayedStateDecorationPolicy { + bool time_left = 1; + bool is_played = 2; + bool playable = 3; + bool playability_restriction = 4; + bool last_played_at = 5; +} + diff --git a/protocol/proto/policy/folder_decoration_policy.proto b/protocol/proto/policy/folder_decoration_policy.proto new file mode 100644 index 00000000..16e3eb11 --- /dev/null +++ b/protocol/proto/policy/folder_decoration_policy.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message FolderDecorationPolicy { + bool row_id = 1; + bool id = 2; + bool link = 3; + bool name = 4; + bool folders = 5; + bool playlists = 6; + bool recursive_folders = 7; + bool recursive_playlists = 8; + bool rows = 9; +} diff --git a/protocol/proto/policy/playlist_album_decoration_policy.proto b/protocol/proto/policy/playlist_album_decoration_policy.proto new file mode 100644 index 00000000..97bbf72b --- /dev/null +++ b/protocol/proto/policy/playlist_album_decoration_policy.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "policy/album_decoration_policy.proto"; +import "policy/artist_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistAlbumDecorationPolicy { + cosmos_util.proto.AlbumDecorationPolicy album = 1; + cosmos_util.proto.ArtistDecorationPolicy artist = 2; +} diff --git a/protocol/proto/policy/playlist_decoration_policy.proto b/protocol/proto/policy/playlist_decoration_policy.proto new file mode 100644 index 00000000..e2376c22 --- /dev/null +++ b/protocol/proto/policy/playlist_decoration_policy.proto @@ -0,0 +1,70 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "extension_kind.proto"; +import "policy/user_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistAllowsDecorationPolicy { + bool insert = 1; + bool remove = 2; +} + +message PlaylistDecorationPolicy { + bool row_id = 1; + bool link = 2; + bool name = 3; + bool load_state = 4; + bool loaded = 5; + bool collaborative = 6; + bool length = 7; + bool last_modification = 8; + bool total_length = 9; + bool duration = 10; + bool description = 11; + bool picture = 12; + bool playable = 13; + bool description_from_annotate = 14; + bool picture_from_annotate = 15; + bool can_report_annotation_abuse = 16; + bool followed = 17; + bool followers = 18; + bool owned_by_self = 19; + bool offline = 20; + bool sync_progress = 21; + bool published = 22; + bool browsable_offline = 23; + bool format_list_type = 24; + bool format_list_attributes = 25; + bool has_explicit_content = 26; + bool contains_spotify_tracks = 27; + bool contains_tracks = 28; + bool contains_episodes = 29; + bool contains_audio_episodes = 30; + bool only_contains_explicit = 31; + bool is_on_demand_in_free = 32; + UserDecorationPolicy owner = 33; + UserDecorationPolicy made_for = 34; + PlaylistAllowsDecorationPolicy allows = 35; + bool number_of_episodes = 36; + bool number_of_tracks = 37; + bool prefer_linear_playback = 38; + bool on_demand_in_free_reason = 39; + CollaboratingUsersDecorationPolicy collaborating_users = 40; + bool base_permission = 41; + bool user_capabilities = 42; + repeated extendedmetadata.ExtensionKind extension = 43; + bool lenses = 44; + bool length_ignoring_text_filter = 45; + bool number_of_items_per_link_type = 46; + bool available_signals = 47; + bool ai_curation_reference_id = 48; + bool unranged_length = 49; + bool unfiltered_length = 50; +} diff --git a/protocol/proto/policy/playlist_episode_decoration_policy.proto b/protocol/proto/policy/playlist_episode_decoration_policy.proto new file mode 100644 index 00000000..04a63b0b --- /dev/null +++ b/protocol/proto/policy/playlist_episode_decoration_policy.proto @@ -0,0 +1,27 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "policy/episode_decoration_policy.proto"; +import "policy/show_decoration_policy.proto"; +import "policy/user_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistEpisodeDecorationPolicy { + cosmos_util.proto.EpisodeDecorationPolicy episode = 1; + bool row_id = 2; + bool add_time = 3; + bool format_list_attributes = 4; + cosmos_util.proto.EpisodeCollectionDecorationPolicy collection = 5; + cosmos_util.proto.EpisodeSyncDecorationPolicy sync = 6; + cosmos_util.proto.EpisodePlayedStateDecorationPolicy played_state = 7; + UserDecorationPolicy added_by = 8; + cosmos_util.proto.ShowDecorationPolicy show = 9; + bool signals = 10; + bool is_recommendation = 11; +} diff --git a/protocol/proto/policy/playlist_request_decoration_policy.proto b/protocol/proto/policy/playlist_request_decoration_policy.proto new file mode 100644 index 00000000..66fdea32 --- /dev/null +++ b/protocol/proto/policy/playlist_request_decoration_policy.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "policy/playlist_decoration_policy.proto"; +import "policy/playlist_episode_decoration_policy.proto"; +import "policy/playlist_track_decoration_policy.proto"; +import "policy/supported_link_types_in_playlists.proto"; +import "extension_kind.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message ItemExtensionPolicy { + LinkType link_type = 1; + extendedmetadata.ExtensionKind extension = 2; +} + +message ItemOfflineStateDecorationPolicy { + bool offline_state = 1; + bool sync_progress = 2; + bool locally_playable = 3; +} + +message ItemMetadataPolicy { + bool name = 1; + bool image = 2; + bool is_explicit = 3; +} + +message ItemCurationStatePolicy { + bool is_curated = 1; +} + +message PlaylistItemDecorationPolicy { + bool uri = 1; + repeated ItemExtensionPolicy extension_policy = 2; + ItemOfflineStateDecorationPolicy offline_state = 3; + bool collection_state = 4; + ItemMetadataPolicy metadata = 5; + ItemCurationStatePolicy curation_state = 6; + bool obfuscation_state = 7; +} + +message PlaylistRequestDecorationPolicy { + PlaylistDecorationPolicy playlist = 1; + PlaylistTrackDecorationPolicy track = 2; + PlaylistEpisodeDecorationPolicy episode = 3; + PlaylistItemDecorationPolicy item = 4; +} diff --git a/protocol/proto/policy/playlist_track_decoration_policy.proto b/protocol/proto/policy/playlist_track_decoration_policy.proto new file mode 100644 index 00000000..cad855ab --- /dev/null +++ b/protocol/proto/policy/playlist_track_decoration_policy.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +import "policy/artist_decoration_policy.proto"; +import "policy/track_decoration_policy.proto"; +import "policy/playlist_album_decoration_policy.proto"; +import "policy/user_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message PlaylistTrackDecorationPolicy { + cosmos_util.proto.TrackDecorationPolicy track = 1; + bool row_id = 2; + bool add_time = 3; + bool in_collection = 4; + bool can_add_to_collection = 5; + bool is_banned = 6; + bool can_ban = 7; + bool local_file = 8; + bool offline = 9; + bool format_list_attributes = 10; + bool display_covers = 11; + UserDecorationPolicy added_by = 12; + PlaylistAlbumDecorationPolicy album = 13; + cosmos_util.proto.ArtistDecorationPolicy artist = 14; + bool signals = 15; + bool is_recommendation = 16; +} diff --git a/protocol/proto/policy/rootlist_folder_decoration_policy.proto b/protocol/proto/policy/rootlist_folder_decoration_policy.proto new file mode 100644 index 00000000..5c58a3f1 --- /dev/null +++ b/protocol/proto/policy/rootlist_folder_decoration_policy.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "policy/folder_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message RootlistFolderDecorationPolicy { + optional bool add_time = 1; + optional FolderDecorationPolicy folder = 2; + optional bool group_label = 3; +} diff --git a/protocol/proto/policy/rootlist_playlist_decoration_policy.proto b/protocol/proto/policy/rootlist_playlist_decoration_policy.proto new file mode 100644 index 00000000..2c052c37 --- /dev/null +++ b/protocol/proto/policy/rootlist_playlist_decoration_policy.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "policy/playlist_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message RootlistPlaylistDecorationPolicy { + optional bool add_time = 1; + optional PlaylistDecorationPolicy playlist = 2; + optional bool group_label = 3; +} diff --git a/protocol/proto/policy/rootlist_request_decoration_policy.proto b/protocol/proto/policy/rootlist_request_decoration_policy.proto new file mode 100644 index 00000000..cf7b5ab9 --- /dev/null +++ b/protocol/proto/policy/rootlist_request_decoration_policy.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "policy/rootlist_folder_decoration_policy.proto"; +import "policy/rootlist_playlist_decoration_policy.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message RootlistRequestDecorationPolicy { + optional bool unfiltered_length = 1; + optional bool unranged_length = 2; + optional bool is_loading_contents = 3; + optional RootlistPlaylistDecorationPolicy playlist = 4; + optional RootlistFolderDecorationPolicy folder = 5; +} diff --git a/protocol/proto/policy/show_decoration_policy.proto b/protocol/proto/policy/show_decoration_policy.proto new file mode 100644 index 00000000..fbb1617a --- /dev/null +++ b/protocol/proto/policy/show_decoration_policy.proto @@ -0,0 +1,51 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +import "extension_kind.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message ShowDecorationPolicy { + reserved 15; + bool link = 1; + bool name = 2; + bool description = 3; + bool popularity = 4; + bool publisher = 5; + bool language = 6; + bool is_explicit = 7; + bool covers = 8; + bool num_episodes = 9; + bool consumption_order = 10; + bool media_type_enum = 11; + bool copyrights = 12; + bool trailer_uri = 13; + bool is_music_and_talk = 14; + repeated extendedmetadata.ExtensionKind extension = 16; + bool is_book = 17; + bool is_creator_channel = 18; +} + +message ShowPlayedStateDecorationPolicy { + bool latest_played_episode_link = 1; + bool played_time = 2; + bool is_playable = 3; + bool playability_restriction = 4; + bool label = 5; + bool resume_episode_link = 7; +} + +message ShowCollectionDecorationPolicy { + bool is_in_collection = 1; +} + +message ShowOfflineStateDecorationPolicy { + bool offline = 1; + bool sync_progress = 2; +} + diff --git a/protocol/proto/policy/supported_link_types_in_playlists.proto b/protocol/proto/policy/supported_link_types_in_playlists.proto new file mode 100644 index 00000000..4c1897a5 --- /dev/null +++ b/protocol/proto/policy/supported_link_types_in_playlists.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_package = "com.spotify.playlist.policy.proto"; +option java_multiple_files = true; + +enum LinkType { + EMPTY = 0; + ARTIST = 1; + ALBUM = 2; + TRACK = 4; + LOCAL_TRACK = 9; + SHOW = 62; + EPISODE = 63; +} + diff --git a/protocol/proto/policy/track_decoration_policy.proto b/protocol/proto/policy/track_decoration_policy.proto new file mode 100644 index 00000000..e68922c9 --- /dev/null +++ b/protocol/proto/policy/track_decoration_policy.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.cosmos_util.proto; + +import "extension_kind.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.policy.proto"; + +message TrackDecorationPolicy { + bool has_lyrics = 1; + bool link = 2; + bool name = 3; + bool length = 4; + bool playable = 5; + bool is_available_in_metadata_catalogue = 6; + bool locally_playable = 7; + bool playable_local_track = 8; + bool disc_number = 9; + bool track_number = 10; + bool is_explicit = 11; + bool preview_id = 12; + bool is_local = 13; + bool is_premium_only = 14; + bool playable_track_link = 15; + bool popularity = 16; + bool is_19_plus_only = 17; + bool track_descriptors = 18; + repeated extendedmetadata.ExtensionKind extension = 19; + bool is_curated = 20; + bool to_be_obfuscated = 22; +} + +message TrackPlayedStateDecorationPolicy { + bool playable = 1; + bool is_currently_playable = 2; + bool playability_restriction = 3; +} + +message TrackCollectionDecorationPolicy { + bool is_in_collection = 1; + bool can_add_to_collection = 2; + bool is_banned = 3; + bool can_ban = 4; +} + +message TrackSyncDecorationPolicy { + bool offline_state = 1; + bool sync_progress = 2; +} diff --git a/protocol/proto/policy/user_decoration_policy.proto b/protocol/proto/policy/user_decoration_policy.proto new file mode 100644 index 00000000..c284df3e --- /dev/null +++ b/protocol/proto/policy/user_decoration_policy.proto @@ -0,0 +1,32 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.policy.proto"; + +message UserDecorationPolicy { + bool username = 1; + bool link = 2; + bool name = 3; + bool image = 4; + bool thumbnail = 5; + bool color = 6; +} + +message CollaboratorPolicy { + UserDecorationPolicy user = 1; + bool number_of_items = 2; + bool number_of_tracks = 3; + bool number_of_episodes = 4; + bool is_owner = 5; +} + +message CollaboratingUsersDecorationPolicy { + bool count = 1; + int32 limit = 2; + CollaboratorPolicy collaborator = 3; +} diff --git a/protocol/proto/popcount.proto b/protocol/proto/popcount.proto deleted file mode 100644 index 7a0bac84..00000000 --- a/protocol/proto/popcount.proto +++ /dev/null @@ -1,13 +0,0 @@ -syntax = "proto2"; - -message PopcountRequest { -} - -message PopcountResult { - optional sint64 count = 0x1; - optional bool truncated = 0x2; - repeated string user = 0x3; - repeated sint64 subscriptionTimestamps = 0x4; - repeated sint64 insertionTimestamps = 0x5; -} - diff --git a/protocol/proto/popcount2_external.proto b/protocol/proto/popcount2_external.proto new file mode 100644 index 00000000..8c69a4b4 --- /dev/null +++ b/protocol/proto/popcount2_external.proto @@ -0,0 +1,35 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.popcount2.proto; + +option optimize_for = CODE_SIZE; + +message PopcountRequest { +} + +message PopcountResult { + optional sint64 count = 1; + optional bool truncated = 2; + repeated string user = 3; + repeated string userid = 6; + optional int64 raw_count = 7; + optional bool count_hidden_from_users = 8; +} + +message PopcountUserUpdate { + optional string user = 1; + optional sint64 timestamp = 2; + optional bool added = 3; + optional string userid = 4; +} + +message PopcountFollowerResult { + optional bool is_truncated = 1; + repeated bytes user_id = 2; +} + +message PopcountSetFollowerCounterValueRequest { + optional int64 count = 1; +} diff --git a/protocol/proto/prepare_play_options.proto b/protocol/proto/prepare_play_options.proto new file mode 100644 index 00000000..9c545824 --- /dev/null +++ b/protocol/proto/prepare_play_options.proto @@ -0,0 +1,37 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_player_options.proto"; +import "player_license.proto"; +import "skip_to_track.proto"; + +option optimize_for = CODE_SIZE; + +message PreparePlayOptions { + optional ContextPlayerOptionOverrides player_options_override = 1; + optional PlayerLicense license = 2; + map configuration_override = 3; + optional string playback_id = 4; + optional bool always_play_something = 5; + optional SkipToTrack skip_to_track = 6; + optional int64 seek_to = 7; + optional bool initially_paused = 8; + optional bool system_initiated = 9; + repeated string suppressions = 10; + optional PrefetchLevel prefetch_level = 11; + optional string session_id = 12; + optional AudioStream audio_stream = 13; +} + +enum PrefetchLevel { + NONE = 0; + MEDIA = 1; +} + +enum AudioStream { + DEFAULT = 0; + ALARM = 1; +} diff --git a/protocol/proto/presence.proto b/protocol/proto/presence.proto deleted file mode 100644 index 5e9be377..00000000 --- a/protocol/proto/presence.proto +++ /dev/null @@ -1,94 +0,0 @@ -syntax = "proto2"; - -message PlaylistPublishedState { - optional string uri = 0x1; - optional int64 timestamp = 0x2; -} - -message PlaylistTrackAddedState { - optional string playlist_uri = 0x1; - optional string track_uri = 0x2; - optional int64 timestamp = 0x3; -} - -message TrackFinishedPlayingState { - optional string uri = 0x1; - optional string context_uri = 0x2; - optional int64 timestamp = 0x3; - optional string referrer_uri = 0x4; -} - -message FavoriteAppAddedState { - optional string app_uri = 0x1; - optional int64 timestamp = 0x2; -} - -message TrackStartedPlayingState { - optional string uri = 0x1; - optional string context_uri = 0x2; - optional int64 timestamp = 0x3; - optional string referrer_uri = 0x4; -} - -message UriSharedState { - optional string uri = 0x1; - optional string message = 0x2; - optional int64 timestamp = 0x3; -} - -message ArtistFollowedState { - optional string uri = 0x1; - optional string artist_name = 0x2; - optional string artist_cover_uri = 0x3; - optional int64 timestamp = 0x4; -} - -message DeviceInformation { - optional string os = 0x1; - optional string type = 0x2; -} - -message GenericPresenceState { - optional int32 type = 0x1; - optional int64 timestamp = 0x2; - optional string item_uri = 0x3; - optional string item_name = 0x4; - optional string item_image = 0x5; - optional string context_uri = 0x6; - optional string context_name = 0x7; - optional string context_image = 0x8; - optional string referrer_uri = 0x9; - optional string referrer_name = 0xa; - optional string referrer_image = 0xb; - optional string message = 0xc; - optional DeviceInformation device_information = 0xd; -} - -message State { - optional int64 timestamp = 0x1; - optional Type type = 0x2; - enum Type { - PLAYLIST_PUBLISHED = 0x1; - PLAYLIST_TRACK_ADDED = 0x2; - TRACK_FINISHED_PLAYING = 0x3; - FAVORITE_APP_ADDED = 0x4; - TRACK_STARTED_PLAYING = 0x5; - URI_SHARED = 0x6; - ARTIST_FOLLOWED = 0x7; - GENERIC = 0xb; - } - optional string uri = 0x3; - optional PlaylistPublishedState playlist_published = 0x4; - optional PlaylistTrackAddedState playlist_track_added = 0x5; - optional TrackFinishedPlayingState track_finished_playing = 0x6; - optional FavoriteAppAddedState favorite_app_added = 0x7; - optional TrackStartedPlayingState track_started_playing = 0x8; - optional UriSharedState uri_shared = 0x9; - optional ArtistFollowedState artist_followed = 0xa; - optional GenericPresenceState generic = 0xb; -} - -message StateList { - repeated State states = 0x1; -} - diff --git a/protocol/proto/profile_cosmos.proto b/protocol/proto/profile_cosmos.proto new file mode 100644 index 00000000..a643e6f3 --- /dev/null +++ b/protocol/proto/profile_cosmos.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.profile_cosmos.proto; + +import "identity.proto"; + +option optimize_for = CODE_SIZE; + +message GetProfilesRequest { + repeated string usernames = 1; +} + +message GetProfilesResponse { + repeated identity.v3.UserProfile profiles = 1; +} + +message ChangeDisplayNameRequest { + string username = 1; + string display_name = 2; +} diff --git a/protocol/proto/profile_service.proto b/protocol/proto/profile_service.proto new file mode 100644 index 00000000..84425e49 --- /dev/null +++ b/protocol/proto/profile_service.proto @@ -0,0 +1,35 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.profile_esperanto.proto.v1; + +import "identity.proto"; + +option java_package = "spotify.profile_esperanto.proto"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; + +service ProfileService { + rpc GetProfiles(GetProfilesRequest) returns (GetProfilesResponse); + rpc SubscribeToProfiles(GetProfilesRequest) returns (stream GetProfilesResponse); + rpc ChangeDisplayName(ChangeDisplayNameRequest) returns (ChangeDisplayNameResponse); +} + +message GetProfilesRequest { + repeated string usernames = 1; +} + +message GetProfilesResponse { + repeated identity.v3.UserProfile profiles = 1; + int32 status_code = 2; +} + +message ChangeDisplayNameRequest { + string username = 1; + string display_name = 2; +} + +message ChangeDisplayNameResponse { + int32 status_code = 1; +} diff --git a/protocol/proto/property_definition.proto b/protocol/proto/property_definition.proto new file mode 100644 index 00000000..277f73f4 --- /dev/null +++ b/protocol/proto/property_definition.proto @@ -0,0 +1,45 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.remote_config.ucs.proto; + +option optimize_for = CODE_SIZE; + +message PropertyDefinition { + reserved "hash"; + reserved 2; + + message BoolSpec { + bool default = 1; + } + + message IntSpec { + int32 default = 1; + int32 lower = 2; + int32 upper = 3; + } + + message EnumSpec { + string default = 1; + repeated string values = 2; + } + + Identifier id = 1; + message Identifier { + string scope = 1; + string name = 2; + } + + Metadata metadata = 4; + message Metadata { + string component_id = 1; + string description = 2; + } + + oneof specification { + BoolSpec bool_spec = 5; + IntSpec int_spec = 6; + EnumSpec enum_spec = 7; + } +} diff --git a/protocol/proto/protobuf_delta.proto b/protocol/proto/protobuf_delta.proto new file mode 100644 index 00000000..95ac7e14 --- /dev/null +++ b/protocol/proto/protobuf_delta.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.protobuf_deltas.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message Delta { + required Type type = 1; + enum Type { + DELETE = 0; + INSERT = 1; + } + + required uint32 index = 2; + required uint32 length = 3; +} diff --git a/protocol/proto/queue.proto b/protocol/proto/queue.proto new file mode 100644 index 00000000..64d10414 --- /dev/null +++ b/protocol/proto/queue.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +import "context_track.proto"; + +option optimize_for = CODE_SIZE; + +message Queue { + repeated ContextTrack tracks = 1; + optional bool is_playing_queue = 2; +} diff --git a/protocol/proto/radio.proto b/protocol/proto/radio.proto deleted file mode 100644 index 7a8f3bde..00000000 --- a/protocol/proto/radio.proto +++ /dev/null @@ -1,58 +0,0 @@ -syntax = "proto2"; - -message RadioRequest { - repeated string uris = 0x1; - optional int32 salt = 0x2; - optional int32 length = 0x4; - optional string stationId = 0x5; - repeated string lastTracks = 0x6; -} - -message MultiSeedRequest { - repeated string uris = 0x1; -} - -message Feedback { - optional string uri = 0x1; - optional string type = 0x2; - optional double timestamp = 0x3; -} - -message Tracks { - repeated string gids = 0x1; - optional string source = 0x2; - optional string identity = 0x3; - repeated string tokens = 0x4; - repeated Feedback feedback = 0x5; -} - -message Station { - optional string id = 0x1; - optional string title = 0x2; - optional string titleUri = 0x3; - optional string subtitle = 0x4; - optional string subtitleUri = 0x5; - optional string imageUri = 0x6; - optional double lastListen = 0x7; - repeated string seeds = 0x8; - optional int32 thumbsUp = 0x9; - optional int32 thumbsDown = 0xa; -} - -message Rules { - optional string js = 0x1; -} - -message StationResponse { - optional Station station = 0x1; - repeated Feedback feedback = 0x2; -} - -message StationList { - repeated Station stations = 0x1; -} - -message LikedPlaylist { - optional string uri = 0x1; -} - diff --git a/protocol/proto/rate_limited_events.proto b/protocol/proto/rate_limited_events.proto new file mode 100644 index 00000000..6d080705 --- /dev/null +++ b/protocol/proto/rate_limited_events.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RateLimitedEventsEntity { + int32 file_format_version = 1; + map map_field = 2; +} diff --git a/protocol/proto/rcs.proto b/protocol/proto/rcs.proto new file mode 100644 index 00000000..8225eb27 --- /dev/null +++ b/protocol/proto/rcs.proto @@ -0,0 +1,105 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.remote_config.proto; + +option optimize_for = CODE_SIZE; + +message GranularConfiguration { + message AssignedPropertyValue { + Platform platform = 7; + string client_id = 4; + string component_id = 5; + int64 groupId = 8; + string name = 6; + oneof structured_value { + BoolValue bool_value = 1; + IntValue int_value = 2; + EnumValue enum_value = 3; + } + + message BoolValue { + bool value = 1; + } + + message IntValue { + int32 value = 1; + } + + message EnumValue { + string value = 1; + } + } + + repeated AssignedPropertyValue properties = 1; + int64 rcs_fetch_time = 2; + string configuration_assignment_id = 3; + string etag = 10; +} + +message PolicyGroupId { + int64 policy_id = 1; + int64 policy_group_id = 2; +} + +message ClientPropertySet { + message ComponentInfo { + reserved "owner"; + reserved "tags"; + reserved 1; + reserved 2; + string name = 3; + } + + message PublisherInfo { + string published_for_client_version = 1; + int64 published_at = 2; + } + + string client_id = 1; + string version = 2; + repeated PropertyDefinition properties = 5; + repeated ComponentInfo component_infos = 6; + string property_set_key = 7; + PublisherInfo publisherInfo = 8; +} + +message PropertyDefinition { + reserved 1; + message BoolSpec { + bool default = 1; + } + + message IntSpec { + int32 default = 1; + int32 lower = 2; + int32 upper = 3; + } + + message EnumSpec { + string default = 1; + repeated string values = 2; + } + + string description = 2; + string component_id = 3; + Platform platform = 8; + oneof identifier { + string id = 9; + string name = 7; + } + oneof spec { + BoolSpec bool_spec = 4; + IntSpec int_spec = 5; + EnumSpec enum_spec = 6; + } +} + +enum Platform { + UNKNOWN_PLATFORM = 0; + ANDROID_PLATFORM = 1; + BACKEND_PLATFORM = 2; + IOS_PLATFORM = 3; + WEB_PLATFORM = 4; +} diff --git a/protocol/proto/recently_played.proto b/protocol/proto/recently_played.proto new file mode 100644 index 00000000..95b8f2cd --- /dev/null +++ b/protocol/proto/recently_played.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.recently_played.proto; + +option optimize_for = CODE_SIZE; + +message Item { + string uri = 1; + int64 timestamp = 2; + bool hidden = 3; +} diff --git a/protocol/proto/recently_played_backend.proto b/protocol/proto/recently_played_backend.proto new file mode 100644 index 00000000..5533f3d3 --- /dev/null +++ b/protocol/proto/recently_played_backend.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.recently_played_backend.proto; + +option optimize_for = CODE_SIZE; + +message Context { + optional string uri = 1; + optional int64 lastPlayedTime = 2; +} + +message RecentlyPlayed { + repeated Context contexts = 1; + optional int32 offset = 2; + optional int32 total = 3; +} diff --git a/protocol/proto/record_id.proto b/protocol/proto/record_id.proto new file mode 100644 index 00000000..5839058a --- /dev/null +++ b/protocol/proto/record_id.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message RecordId { + uint64 value = 1; +} diff --git a/protocol/proto/remote.proto b/protocol/proto/remote.proto new file mode 100644 index 00000000..06a1d322 --- /dev/null +++ b/protocol/proto/remote.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.shuffle.remote; + +option optimize_for = CODE_SIZE; + +message ServiceRequest { + repeated Track tracks = 1; + message Track { + required string uri = 1; + required string uid = 2; + } +} + +message ServiceResponse { + repeated uint32 order = 1; +} diff --git a/protocol/proto/repeating_track_node.proto b/protocol/proto/repeating_track_node.proto new file mode 100644 index 00000000..d4691cd2 --- /dev/null +++ b/protocol/proto/repeating_track_node.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "track_instance.proto"; +import "track_instantiator.proto"; + +option optimize_for = CODE_SIZE; + +message RepeatingTrackNode { + optional TrackInstance instance = 1; + optional TrackInstantiator instantiator = 2; +} diff --git a/protocol/proto/request_failure.proto b/protocol/proto/request_failure.proto new file mode 100644 index 00000000..50445542 --- /dev/null +++ b/protocol/proto/request_failure.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.image.proto; + +option optimize_for = CODE_SIZE; + +message RequestFailure { + optional string request = 1; + optional string source = 2; + optional string error = 3; + optional int64 result = 4; +} diff --git a/protocol/proto/resolve.proto b/protocol/proto/resolve.proto new file mode 100644 index 00000000..e0af636e --- /dev/null +++ b/protocol/proto/resolve.proto @@ -0,0 +1,118 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.remote_config.ucs.proto; + +import "property_definition.proto"; + +option optimize_for = CODE_SIZE; + +message ResolveRequest { + reserved "custom_context"; + reserved "projection"; + reserved 4; + reserved 5; + string property_set_id = 1; + Fetch fetch_type = 2; + Context context = 11; + oneof resolution_context { + BackendContext backend_context = 12; + } +} + +message ResolveResponse { + Configuration configuration = 1; +} + +message Configuration { + message AssignedValue { + message Metadata { + int64 policy_id = 1; + string external_realm = 2; + int64 external_realm_id = 3; + } + + message BoolValue { + bool value = 1; + } + + message IntValue { + int32 value = 1; + } + + message EnumValue { + string value = 1; + } + + PropertyDefinition.Identifier property_id = 1; + Metadata metadata = 2; + oneof structured_value { + BoolValue bool_value = 3; + IntValue int_value = 4; + EnumValue enum_value = 5; + } + } + + string configuration_assignment_id = 1; + int64 fetch_time_millis = 2; + repeated AssignedValue assigned_values = 3; +} + +message Fetch { + enum Type { + BLOCKING = 0; + BACKGROUND_SYNC = 1; + ASYNC = 2; + PUSH_INITIATED = 3; + RECONNECT = 4; + } + + Type type = 1; +} + +message Context { + message ContextEntry { + string value = 10; + oneof context { + DynamicContext.KnownContext known_context = 1; + string policy_input_name = 2; + } + } + + repeated ContextEntry context = 1; +} + +message BackendContext { + message StaticContext { + string system = 1; + string service_name = 2; + } + + message SurfaceMetadata { + string backend_sdk_version = 1; + } + + string system = 1; + string service_name = 2; + StaticContext static_context = 3; + DynamicContext dynamic_context = 4; + SurfaceMetadata surface_metadata = 10; +} + +message DynamicContext { + message ContextDefinition { + oneof context { + KnownContext known_context = 1; + } + } + + enum KnownContext { + KNOWN_CONTEXT_INVALID = 0; + KNOWN_CONTEXT_USER_ID = 1; + KNOWN_CONTEXT_INSTALLATION_ID = 2; + KNOWN_CONTEXT_VERSION = 3; + } + + repeated ContextDefinition context_definition = 1; +} diff --git a/protocol/proto/resource_type.proto b/protocol/proto/resource_type.proto new file mode 100644 index 00000000..0803390c --- /dev/null +++ b/protocol/proto/resource_type.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.offline.proto; + +option optimize_for = CODE_SIZE; + +enum ResourceType { + OTHER = 0; + AUDIO = 1; + DRM = 2; + IMAGE = 3; + VIDEO = 4; +} diff --git a/protocol/proto/response_status.proto b/protocol/proto/response_status.proto new file mode 100644 index 00000000..af2717d9 --- /dev/null +++ b/protocol/proto/response_status.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist_esperanto.proto; + +option objc_class_prefix = "SPTPlaylistEsperanto"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.playlist.esperanto.proto"; + +message ResponseStatus { + int32 status_code = 1; + string reason = 2; +} diff --git a/protocol/proto/restrictions.proto b/protocol/proto/restrictions.proto new file mode 100644 index 00000000..7cf3521d --- /dev/null +++ b/protocol/proto/restrictions.proto @@ -0,0 +1,46 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message ModeRestrictions { + map values = 1; +} + +message RestrictionReasons { + repeated string reasons = 1; +} + +message Restrictions { + reserved 24; + + repeated string disallow_pausing_reasons = 1; + repeated string disallow_resuming_reasons = 2; + repeated string disallow_seeking_reasons = 3; + repeated string disallow_peeking_prev_reasons = 4; + repeated string disallow_peeking_next_reasons = 5; + repeated string disallow_skipping_prev_reasons = 6; + repeated string disallow_skipping_next_reasons = 7; + repeated string disallow_toggling_repeat_context_reasons = 8; + repeated string disallow_toggling_repeat_track_reasons = 9; + repeated string disallow_toggling_shuffle_reasons = 10; + repeated string disallow_set_queue_reasons = 11; + repeated string disallow_interrupting_playback_reasons = 12; + repeated string disallow_transferring_playback_reasons = 13; + repeated string disallow_remote_control_reasons = 14; + repeated string disallow_inserting_into_next_tracks_reasons = 15; + repeated string disallow_inserting_into_context_tracks_reasons = 16; + repeated string disallow_reordering_in_next_tracks_reasons = 17; + repeated string disallow_reordering_in_context_tracks_reasons = 18; + repeated string disallow_removing_from_next_tracks_reasons = 19; + repeated string disallow_removing_from_context_tracks_reasons = 20; + repeated string disallow_updating_context_reasons = 21; + repeated string disallow_add_to_queue_reasons = 22; + repeated string disallow_setting_playback_speed = 23; + map disallow_setting_modes = 25; + map disallow_signals = 26; +} + diff --git a/protocol/proto/resume_points_node.proto b/protocol/proto/resume_points_node.proto new file mode 100644 index 00000000..9f7eed8e --- /dev/null +++ b/protocol/proto/resume_points_node.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify_shows.proto; + +option optimize_for = CODE_SIZE; + +message ResumePointsNode { + optional int64 resume_point = 1; +} diff --git a/protocol/proto/rootlist_request.proto b/protocol/proto/rootlist_request.proto new file mode 100644 index 00000000..b21e41e0 --- /dev/null +++ b/protocol/proto/rootlist_request.proto @@ -0,0 +1,46 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.rootlist_request.proto; + +import "playlist_folder_state.proto"; +import "playlist_permission.proto"; +import "playlist_playlist_state.proto"; +import "protobuf_delta.proto"; + +option objc_class_prefix = "SPTPlaylistCosmosRootlist"; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message Playlist { + optional string row_id = 1; + optional cosmos.proto.PlaylistMetadata playlist_metadata = 2; + optional cosmos.proto.PlaylistOfflineState playlist_offline_state = 3; + optional uint32 add_time = 4; + optional bool is_on_demand_in_free = 5; + optional string group_label = 6; + optional playlist_permission.proto.Capabilities capabilities = 7; +} + +message Item { + optional string header_field = 1; + optional Folder folder = 2; + optional Playlist playlist = 3; + optional protobuf_deltas.proto.Delta delta = 4; +} + +message Folder { + repeated Item item = 1; + optional cosmos.proto.FolderMetadata folder_metadata = 2; + optional string row_id = 3; + optional uint32 add_time = 4; + optional string group_label = 5; +} + +message Response { + optional Folder root = 1; + optional int32 unfiltered_length = 2; + optional int32 unranged_length = 3; + optional bool is_loading_contents = 4; +} diff --git a/protocol/proto/search.proto b/protocol/proto/search.proto deleted file mode 100644 index 38b717f7..00000000 --- a/protocol/proto/search.proto +++ /dev/null @@ -1,44 +0,0 @@ -syntax = "proto2"; - -message SearchRequest { - optional string query = 0x1; - optional Type type = 0x2; - enum Type { - TRACK = 0x0; - ALBUM = 0x1; - ARTIST = 0x2; - PLAYLIST = 0x3; - USER = 0x4; - } - optional int32 limit = 0x3; - optional int32 offset = 0x4; - optional bool did_you_mean = 0x5; - optional string spotify_uri = 0x2; - repeated bytes file_id = 0x3; - optional string url = 0x4; - optional string slask_id = 0x5; -} - -message Playlist { - optional string uri = 0x1; - optional string name = 0x2; - repeated Image image = 0x3; -} - -message User { - optional string username = 0x1; - optional string full_name = 0x2; - repeated Image image = 0x3; - optional sint32 followers = 0x4; -} - -message SearchReply { - optional sint32 hits = 0x1; - repeated Track track = 0x2; - repeated Album album = 0x3; - repeated Artist artist = 0x4; - repeated Playlist playlist = 0x5; - optional string did_you_mean = 0x6; - repeated User user = 0x7; -} - diff --git a/protocol/proto/seek_to_position.proto b/protocol/proto/seek_to_position.proto new file mode 100644 index 00000000..ec5cc67c --- /dev/null +++ b/protocol/proto/seek_to_position.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message SeekToPosition { + optional uint64 value = 1; + optional uint32 revision = 2; +} diff --git a/protocol/proto/sequence_number_entity.proto b/protocol/proto/sequence_number_entity.proto new file mode 100644 index 00000000..7f78d699 --- /dev/null +++ b/protocol/proto/sequence_number_entity.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.event_sender.proto; + +option optimize_for = CODE_SIZE; + +message SequenceNumberEntity { + uint32 file_format_version = 1; + string event_name = 2; + bytes sequence_id = 3; + uint64 sequence_number_next = 4; +} diff --git a/protocol/proto/session.proto b/protocol/proto/session.proto new file mode 100644 index 00000000..6038cdf6 --- /dev/null +++ b/protocol/proto/session.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +import "context.proto"; +import "context_player_options.proto"; +import "play_origin.proto"; +import "suppressions.proto"; +import "instrumentation_params.proto"; + +option optimize_for = CODE_SIZE; + +message Session { + optional PlayOrigin play_origin = 1; + optional Context context = 2; + optional string current_uid = 3; + optional ContextPlayerOptionOverrides option_overrides = 4; + optional Suppressions suppressions = 5; + optional InstrumentationParams instrumentation_params = 6; + optional string shuffle_seed = 7; + optional Context main_context = 8; + optional string original_session_id = 9; +} diff --git a/protocol/proto/set_member_permission_request.proto b/protocol/proto/set_member_permission_request.proto new file mode 100644 index 00000000..f35be29e --- /dev/null +++ b/protocol/proto/set_member_permission_request.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.playlist.cosmos.proto; + +import "playlist_permission.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message SetMemberPermissionRequest { + optional string playlist_uri = 1; + optional string username = 2; + optional playlist_permission.proto.PermissionLevel permission_level = 3; + optional uint32 timeout_ms = 4; +} diff --git a/protocol/proto/show_access.proto b/protocol/proto/show_access.proto new file mode 100644 index 00000000..54a9cee6 --- /dev/null +++ b/protocol/proto/show_access.proto @@ -0,0 +1,121 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.podcast_paywalls; + +import "spotify/audiobookcashier/v1/audiobook_price.proto"; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "ShowAccessProto"; +option java_package = "com.spotify.podcast.access.proto"; + +message ShowAccess { + reserved 7; + AccountLinkPrompt prompt = 5; + bool is_user_member_of_at_least_one_group = 8; + repeated UnlockingMethod unlocked_by = 10; + repeated UnlockingMethod unlocking_methods = 14; + Signifier signifier = 15; + Disclaimer disclaimer = 16; + oneof explanation { + NoExplanation none = 1; + LegacyExplanation legacy = 2; + BasicExplanation basic = 3; + UpsellLinkExplanation upsellLink = 4; + EngagementExplanation engagement = 6; + MultiPassExplanation multiPass = 9; + CheckoutOnWebOverlayExplanation checkoutOnWebOverlay = 11; + FreeCheckoutExplanation freeCheckout = 12; + ConsumptionCappedExplanation consumptionCapped = 13; + } +} + +message Signifier { + string text = 1; +} + +message BasicExplanation { + string title = 1; + string body = 2; + string cta = 3; +} + +message LegacyExplanation { +} + +message NoExplanation { +} + +message UpsellLinkExplanation { + string title = 1; + string body = 2; + string cta = 3; + string url = 4; +} + +message EngagementExplanation { + string header = 1; + string title = 2; + string body = 3; + string cta = 4; + string dismiss = 5; + string action_type = 6; + string body_secondary = 7; +} + +message CheckoutOnWebOverlayExplanation { + string cta = 1; + string snackbar_success = 2; + string snackbar_error = 3; + string snackbar_fulfilment_complete = 4; + audiobookcashier.v1.AudiobookPrice price = 5; + bool is_price_displayed = 6; +} + +message FreeCheckoutExplanation { + string snackbar_awaiting_fulfilment = 1; +} + +message ConsumptionCappedExplanation { + string title = 1; + string body = 2; + string cta = 3; +} + +message MultiPassExplanation { + string title = 1; + string soa_description = 2; + repeated .spotify.podcast_paywalls.SOAPartner soa_partner = 3; +} + +message SOAPartner { + string display_name = 1; + string link_url = 2; + string logo_url = 3; +} + +message AccountLinkPrompt { + string title = 1; + string body = 2; + string cta = 3; + string url = 4; +} + +message Disclaimer { + string title = 1; + string body = 2; +} + +enum UnlockingMethod { + UNKNOWN = 0; + ANCHOR_PAYWALL = 1; + OAP_OTP = 2; + OAP_LINKING = 3; + AUDIOBOOK_DIRECT_SALES = 4; + ABP = 5; + AUDIOBOOK_PROMOTION = 6; +} + diff --git a/protocol/proto/show_episode_state.proto b/protocol/proto/show_episode_state.proto new file mode 100644 index 00000000..d9c52879 --- /dev/null +++ b/protocol/proto/show_episode_state.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.show_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message EpisodeCollectionState { + optional bool is_following_show = 1; + optional bool is_new = 2; + optional bool is_in_listen_later = 3; +} + +message EpisodeOfflineState { + optional string offline_state = 1; + optional uint32 sync_progress = 2; +} diff --git a/protocol/proto/show_offline_state.proto b/protocol/proto/show_offline_state.proto new file mode 100644 index 00000000..a3cd8ae9 --- /dev/null +++ b/protocol/proto/show_offline_state.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; + +package spotify.show_cosmos.proto; + +message ShowOfflineState { + optional string offline_state = 1; + optional uint32 sync_progress = 2; +} + diff --git a/protocol/proto/show_request.proto b/protocol/proto/show_request.proto new file mode 100644 index 00000000..75c3a69e --- /dev/null +++ b/protocol/proto/show_request.proto @@ -0,0 +1,95 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.show_cosmos.proto; + +import "metadata/episode_metadata.proto"; +import "metadata/show_metadata.proto"; +import "played_state/episode_played_state.proto"; +import "played_state/show_played_state.proto"; +import "show_episode_state.proto"; +import "show_show_state.proto"; +import "show_offline_state.proto"; + +option objc_class_prefix = "SPTShowCosmos"; +option optimize_for = CODE_SIZE; + +message Item { + reserved 6; + reserved 7; + reserved 8; + reserved 9; + optional string header_field = 1; + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2; + optional EpisodeCollectionState episode_collection_state = 3; + optional EpisodeOfflineState episode_offline_state = 4; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 5; +} + +message Header { + optional cosmos_util.proto.ShowMetadata show_metadata = 1; + optional ShowCollectionState show_collection_state = 2; + optional cosmos_util.proto.ShowPlayState show_play_state = 3; + optional ShowOfflineState show_offline_state = 4; +} + +message Response { + reserved "online_data"; + reserved 3; + reserved 9; + repeated Item item = 1; + optional Header header = 2; + optional uint32 unfiltered_length = 4; + optional uint32 length = 5; + optional bool loading_contents = 6; + optional uint32 unranged_length = 7; + optional AuxiliarySections auxiliary_sections = 8; + optional uint32 range_offset = 10; +} + +message AuxiliarySections { + reserved 2; + reserved 4; + reserved 5; + reserved 6; + reserved 7; + reserved 8; + optional ContinueListeningSection continue_listening = 1; + optional TrailerSection trailer_section = 3; + optional LatestUnplayedEpisodeSection latest_unplayed_episode_section = 9; + optional NextBestEpisodeSection next_best_episode_section = 10; + optional SavedEpisodesSection saved_episodes_section = 11; +} + +message ContinueListeningSection { + optional Item item = 1; +} + +message TrailerSection { + optional Item item = 1; +} + +message LatestUnplayedEpisodeSection { + optional Item item = 1; +} + +message NextBestEpisodeSection { + enum Label { + UNKNOWN = 0; + TRAILER = 1; + CONTINUE_LISTENING = 2; + LATEST_PUBLISHED = 3; + UP_NEXT = 4; + FIRST_PUBLISHED = 5; + } + + optional Label label = 1; + optional Item item = 2; +} + +message SavedEpisodesSection { + optional uint32 saved_episodes_count = 1; + optional uint32 downloaded_episodes_count = 2; +} + diff --git a/protocol/proto/show_show_state.proto b/protocol/proto/show_show_state.proto new file mode 100644 index 00000000..126b19e3 --- /dev/null +++ b/protocol/proto/show_show_state.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.show_cosmos.proto; + +option objc_class_prefix = "SPTShowCosmos"; +option optimize_for = CODE_SIZE; + +message ShowCollectionState { + optional bool is_in_collection = 1; +} + diff --git a/protocol/proto/signal-model.proto b/protocol/proto/signal-model.proto new file mode 100644 index 00000000..d3824455 --- /dev/null +++ b/protocol/proto/signal-model.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.signal.proto; + +option java_package = "com.spotify.playlist_signal.model.proto"; +option java_outer_classname = "SignalModelProto"; +option optimize_for = CODE_SIZE; + +message Signal { + string identifier = 1; + bytes data = 2; + bytes client_payload = 3; +} + diff --git a/protocol/proto/skip_to_track.proto b/protocol/proto/skip_to_track.proto new file mode 100644 index 00000000..2d7830ef --- /dev/null +++ b/protocol/proto/skip_to_track.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message SkipToTrack { + optional string page_url = 1; + optional uint64 page_index = 2; + optional string track_uid = 3; + optional string track_uri = 4; + optional uint64 track_index = 5; +} diff --git a/protocol/proto/social.proto b/protocol/proto/social.proto deleted file mode 100644 index 58d39a18..00000000 --- a/protocol/proto/social.proto +++ /dev/null @@ -1,12 +0,0 @@ -syntax = "proto2"; - -message DecorationData { - optional string username = 0x1; - optional string full_name = 0x2; - optional string image_url = 0x3; - optional string large_image_url = 0x5; - optional string first_name = 0x6; - optional string last_name = 0x7; - optional string facebook_uid = 0x8; -} - diff --git a/protocol/proto/social_connect_v2.proto b/protocol/proto/social_connect_v2.proto new file mode 100644 index 00000000..86b3a38e --- /dev/null +++ b/protocol/proto/social_connect_v2.proto @@ -0,0 +1,73 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package socialconnect; + +option optimize_for = CODE_SIZE; + +message Session { + reserved 8; + int64 timestamp = 1; + string session_id = 2; + string join_session_token = 3; + string join_session_url = 4; + string session_owner_id = 5; + repeated SessionMember session_members = 6; + string join_session_uri = 7; + bool is_session_owner = 9; + bool is_listening = 10; + bool is_controlling = 11; + bool is_discoverable = 12; + SessionType initial_session_type = 13; + optional string host_active_device_id = 14; +} + +message SessionMember { + int64 timestamp = 1; + string id = 2; + string username = 3; + string display_name = 4; + string image_url = 5; + string large_image_url = 6; + bool is_listening = 7; + bool is_controlling = 8; +} + +message SessionUpdate { + Session session = 1; + SessionUpdateReason reason = 2; + repeated SessionMember updated_session_members = 3; +} + +message DevicesExposure { + int64 timestamp = 1; + map devices_exposure = 2; +} + +enum SessionType { + UNKNOWN_SESSION_TYPE = 0; + IN_PERSON = 3; + REMOTE = 4; + REMOTE_V2 = 5; +} + +enum SessionUpdateReason { + UNKNOWN_UPDATE_TYPE = 0; + NEW_SESSION = 1; + USER_JOINED = 2; + USER_LEFT = 3; + SESSION_DELETED = 4; + YOU_LEFT = 5; + YOU_WERE_KICKED = 6; + YOU_JOINED = 7; + PARTICIPANT_PROMOTED_TO_HOST = 8; + DISCOVERABILITY_CHANGED = 9; + USER_KICKED = 10; +} + +enum DeviceExposureStatus { + NOT_EXPOSABLE = 0; + NOT_EXPOSED = 1; + EXPOSED = 2; +} diff --git a/protocol/proto/social_service.proto b/protocol/proto/social_service.proto new file mode 100644 index 00000000..05875dc7 --- /dev/null +++ b/protocol/proto/social_service.proto @@ -0,0 +1,50 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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 { + enum Error { + NONE = 0; + FAILED_TO_CONNECT = 1; + USER_DATA_FAIL = 2; + PERMISSIONS = 3; + SERVICE_CONNECT_NOT_PERMITTED = 4; + USER_UNAUTHORIZED = 5; + } + + Error status = 1; + string description = 2; +} + +message SubscribeToStateRequest { +} + +message SubscribeToStateResponse { + bool available = 1; + bool enabled = 2; + repeated string missingPermissions = 3; + string accessToken = 4; +} + diff --git a/protocol/proto/socialgraph.proto b/protocol/proto/socialgraph.proto deleted file mode 100644 index 3adc1306..00000000 --- a/protocol/proto/socialgraph.proto +++ /dev/null @@ -1,49 +0,0 @@ -syntax = "proto2"; - -message CountReply { - repeated int32 counts = 0x1; -} - -message UserListRequest { - optional string last_result = 0x1; - optional int32 count = 0x2; - optional bool include_length = 0x3; -} - -message UserListReply { - repeated User users = 0x1; - optional int32 length = 0x2; -} - -message User { - optional string username = 0x1; - optional int32 subscriber_count = 0x2; - optional int32 subscription_count = 0x3; -} - -message ArtistListReply { - repeated Artist artists = 0x1; -} - -message Artist { - optional string artistid = 0x1; - optional int32 subscriber_count = 0x2; -} - -message StringListRequest { - repeated string args = 0x1; -} - -message StringListReply { - repeated string reply = 0x1; -} - -message TopPlaylistsRequest { - optional string username = 0x1; - optional int32 count = 0x2; -} - -message TopPlaylistsReply { - repeated string uris = 0x1; -} - diff --git a/protocol/proto/socialgraph_response_status.proto b/protocol/proto/socialgraph_response_status.proto new file mode 100644 index 00000000..91acaddf --- /dev/null +++ b/protocol/proto/socialgraph_response_status.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..f365f2f9 --- /dev/null +++ b/protocol/proto/socialgraphv2.proto @@ -0,0 +1,45 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.socialgraph.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.socialgraph.proto"; + +message SocialGraphEntity { + optional string user_uri = 1; + optional string artist_uri = 2; + optional int32 followers_count = 3; + optional int32 following_count = 4; + optional int32 status = 5; + optional bool is_following = 6; + optional bool is_followed = 7; + optional bool is_dismissed = 8; + optional bool is_blocked = 9; + optional int64 following_at = 10; + optional int64 followed_at = 11; + optional int64 dismissed_at = 12; + optional int64 blocked_at = 13; +} + +message SocialGraphRequest { + repeated string target_uris = 1; + optional string source_uri = 2; +} + +message SocialGraphReply { + repeated SocialGraphEntity entities = 1; + optional int32 num_total_entities = 2; +} + +message ChangeNotification { + optional EventType event_type = 1; + repeated SocialGraphEntity entities = 2; +} + +enum EventType { + FOLLOW = 1; + UNFOLLOW = 2; +} diff --git a/protocol/proto/spotify/audiobookcashier/v1/audiobook_price.proto b/protocol/proto/spotify/audiobookcashier/v1/audiobook_price.proto new file mode 100755 index 00000000..40352742 --- /dev/null +++ b/protocol/proto/spotify/audiobookcashier/v1/audiobook_price.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package spotify.audiobookcashier.v1; + +option java_package = "com.spotify.audiobookcashier.v1"; +option java_multiple_files = true; + +message Price { + double amount = 1; + string currency = 2; + string formatted_price = 3; +} + +message AudiobookPrice { + .spotify.audiobookcashier.v1.Price final_price = 1; + .spotify.audiobookcashier.v1.Price final_list_price = 2; +} + diff --git a/protocol/proto/spotify/clienttoken/v0/clienttoken_http.proto b/protocol/proto/spotify/clienttoken/v0/clienttoken_http.proto new file mode 100644 index 00000000..c60cdcaf --- /dev/null +++ b/protocol/proto/spotify/clienttoken/v0/clienttoken_http.proto @@ -0,0 +1,126 @@ +syntax = "proto3"; + +package spotify.clienttoken.http.v0; + +import "connectivity.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.clienttoken.http.v0"; + +message ClientTokenRequest { + ClientTokenRequestType request_type = 1; + + oneof request { + ClientDataRequest client_data = 2; + ChallengeAnswersRequest challenge_answers = 3; + } +} + +message ClientDataRequest { + string client_version = 1; + string client_id = 2; + + oneof data { + spotify.clienttoken.data.v0.ConnectivitySdkData connectivity_sdk_data = 3; + } +} + +message ChallengeAnswersRequest { + string state = 1; + repeated ChallengeAnswer answers = 2; +} + +message ClientTokenResponse { + ClientTokenResponseType response_type = 1; + + oneof response { + GrantedTokenResponse granted_token = 2; + ChallengesResponse challenges = 3; + } +} + +message TokenDomain { + string domain = 1; +} + +message GrantedTokenResponse { + string token = 1; + int32 expires_after_seconds = 2; + int32 refresh_after_seconds = 3; + repeated TokenDomain domains = 4; +} + +message ChallengesResponse { + string state = 1; + repeated Challenge challenges = 2; +} + +message ClientSecretParameters { + string salt = 1; +} + +message EvaluateJSParameters { + string code = 1; + repeated string libraries = 2; +} + +message HashCashParameters { + int32 length = 1; + string prefix = 2; +} + +message Challenge { + ChallengeType type = 1; + + oneof parameters { + ClientSecretParameters client_secret_parameters = 2; + EvaluateJSParameters evaluate_js_parameters = 3; + HashCashParameters evaluate_hashcash_parameters = 4; + } +} + +message ClientSecretHMACAnswer { + string hmac = 1; +} + +message EvaluateJSAnswer { + string result = 1; +} + +message HashCashAnswer { + string suffix = 1; +} + +message ChallengeAnswer { + ChallengeType ChallengeType = 1; + + oneof answer { + ClientSecretHMACAnswer client_secret = 2; + EvaluateJSAnswer evaluate_js = 3; + HashCashAnswer hash_cash = 4; + } +} + +message ClientTokenBadRequest { + string message = 1; +} + +enum ClientTokenRequestType { + REQUEST_UNKNOWN = 0; + REQUEST_CLIENT_DATA_REQUEST = 1; + REQUEST_CHALLENGE_ANSWERS_REQUEST = 2; +} + +enum ClientTokenResponseType { + RESPONSE_UNKNOWN = 0; + RESPONSE_GRANTED_TOKEN_RESPONSE = 1; + RESPONSE_CHALLENGES_RESPONSE = 2; +} + +enum ChallengeType { + CHALLENGE_UNKNOWN = 0; + CHALLENGE_CLIENT_SECRET_HMAC = 1; + CHALLENGE_EVALUATE_JS = 2; + CHALLENGE_HASH_CASH = 3; +} diff --git a/protocol/proto/spotify/login5/v3/challenges/code.proto b/protocol/proto/spotify/login5/v3/challenges/code.proto new file mode 100644 index 00000000..980d3de3 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/challenges/code.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3.challenges; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.challenges.proto"; + +message CodeChallenge { + Method method = 1; + enum Method { + UNKNOWN = 0; + SMS = 1; + } + + int32 code_length = 2; + int32 expires_in = 3; + string canonical_phone_number = 4; +} + +message CodeSolution { + string code = 1; +} diff --git a/protocol/proto/spotify/login5/v3/challenges/hashcash.proto b/protocol/proto/spotify/login5/v3/challenges/hashcash.proto new file mode 100644 index 00000000..3e83981c --- /dev/null +++ b/protocol/proto/spotify/login5/v3/challenges/hashcash.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3.challenges; + +import "google/protobuf/duration.proto"; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.challenges.proto"; + +message HashcashChallenge { + bytes prefix = 1; + int32 length = 2; +} + +message HashcashSolution { + bytes suffix = 1; + google.protobuf.Duration duration = 2; +} diff --git a/protocol/proto/spotify/login5/v3/client_info.proto b/protocol/proto/spotify/login5/v3/client_info.proto new file mode 100644 index 00000000..575891e1 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/client_info.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.proto"; + +message ClientInfo { + string client_id = 1; + string device_id = 2; +} diff --git a/protocol/proto/spotify/login5/v3/credentials/credentials.proto b/protocol/proto/spotify/login5/v3/credentials/credentials.proto new file mode 100644 index 00000000..c1f43953 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/credentials/credentials.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3.credentials; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.credentials.proto"; + +message StoredCredential { + string username = 1; + bytes data = 2; +} + +message Password { + string id = 1; + string password = 2; + bytes padding = 3; +} + +message FacebookAccessToken { + string fb_uid = 1; + string access_token = 2; +} + +message OneTimeToken { + string token = 1; +} + +message ParentChildCredential { + string child_id = 1; + StoredCredential parent_stored_credential = 2; +} + +message AppleSignInCredential { + string auth_code = 1; + string redirect_uri = 2; + string bundle_id = 3; +} + +message SamsungSignInCredential { + string auth_code = 1; + string redirect_uri = 2; + string id_token = 3; + string token_endpoint_url = 4; +} + +message GoogleSignInCredential { + string auth_code = 1; + string redirect_uri = 2; +} diff --git a/protocol/proto/spotify/login5/v3/identifiers/identifiers.proto b/protocol/proto/spotify/login5/v3/identifiers/identifiers.proto new file mode 100644 index 00000000..b82e9942 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/identifiers/identifiers.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3.identifiers; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.identifiers.proto"; + +message PhoneNumber { + string number = 1; + string iso_country_code = 2; + string country_calling_code = 3; +} diff --git a/protocol/proto/spotify/login5/v3/login5.proto b/protocol/proto/spotify/login5/v3/login5.proto new file mode 100644 index 00000000..4b41dcb2 --- /dev/null +++ b/protocol/proto/spotify/login5/v3/login5.proto @@ -0,0 +1,94 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3; + +import "spotify/login5/v3/client_info.proto"; +import "spotify/login5/v3/user_info.proto"; +import "spotify/login5/v3/challenges/code.proto"; +import "spotify/login5/v3/challenges/hashcash.proto"; +import "spotify/login5/v3/credentials/credentials.proto"; +import "spotify/login5/v3/identifiers/identifiers.proto"; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.proto"; + +message Challenges { + repeated Challenge challenges = 1; +} + +message Challenge { + oneof challenge { + challenges.HashcashChallenge hashcash = 1; + challenges.CodeChallenge code = 2; + } +} + +message ChallengeSolutions { + repeated ChallengeSolution solutions = 1; +} + +message ChallengeSolution { + oneof solution { + challenges.HashcashSolution hashcash = 1; + challenges.CodeSolution code = 2; + } +} + +message LoginRequest { + ClientInfo client_info = 1; + bytes login_context = 2; + ChallengeSolutions challenge_solutions = 3; + + oneof login_method { + credentials.StoredCredential stored_credential = 100; + credentials.Password password = 101; + credentials.FacebookAccessToken facebook_access_token = 102; + identifiers.PhoneNumber phone_number = 103; + credentials.OneTimeToken one_time_token = 104; + credentials.ParentChildCredential parent_child_credential = 105; + credentials.AppleSignInCredential apple_sign_in_credential = 106; + credentials.SamsungSignInCredential samsung_sign_in_credential = 107; + credentials.GoogleSignInCredential google_sign_in_credential = 108; + } +} + +message LoginOk { + string username = 1; + string access_token = 2; + bytes stored_credential = 3; + int32 access_token_expires_in = 4; +} + +message LoginResponse { + repeated Warnings warnings = 4; + enum Warnings { + UNKNOWN_WARNING = 0; + DEPRECATED_PROTOCOL_VERSION = 1; + } + + bytes login_context = 5; + string identifier_token = 6; + UserInfo user_info = 7; + + oneof response { + LoginOk ok = 1; + LoginError error = 2; + Challenges challenges = 3; + } +} + +enum LoginError { + UNKNOWN_ERROR = 0; + INVALID_CREDENTIALS = 1; + BAD_REQUEST = 2; + UNSUPPORTED_LOGIN_PROTOCOL = 3; + TIMEOUT = 4; + UNKNOWN_IDENTIFIER = 5; + TOO_MANY_ATTEMPTS = 6; + INVALID_PHONENUMBER = 7; + TRY_AGAIN_LATER = 8; +} diff --git a/protocol/proto/spotify/login5/v3/user_info.proto b/protocol/proto/spotify/login5/v3/user_info.proto new file mode 100644 index 00000000..a7e040cc --- /dev/null +++ b/protocol/proto/spotify/login5/v3/user_info.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.1.33.569 (Windows) + +syntax = "proto3"; + +package spotify.login5.v3; + +option objc_class_prefix = "SPTLogin5"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.login5.v3.proto"; + +message UserInfo { + string name = 1; + string email = 2; + bool email_verified = 3; + string birthdate = 4; + + Gender gender = 5; + enum Gender { + UNKNOWN = 0; + MALE = 1; + FEMALE = 2; + NEUTRAL = 3; + } + + string phone_number = 6; + bool phone_number_verified = 7; + bool email_already_registered = 8; +} diff --git a/protocol/proto/state_restore/ads_rules_inject_tracks.proto b/protocol/proto/state_restore/ads_rules_inject_tracks.proto new file mode 100644 index 00000000..8dfaa6f3 --- /dev/null +++ b/protocol/proto/state_restore/ads_rules_inject_tracks.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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/automix_rules.proto b/protocol/proto/state_restore/automix_rules.proto new file mode 100644 index 00000000..8421c7c8 --- /dev/null +++ b/protocol/proto/state_restore/automix_rules.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +message AutomixRules { + required bool automix = 1; + required string current_track_uri = 2; +} + diff --git a/protocol/proto/state_restore/automix_talk_rules.proto b/protocol/proto/state_restore/automix_talk_rules.proto new file mode 100644 index 00000000..8fb15713 --- /dev/null +++ b/protocol/proto/state_restore/automix_talk_rules.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/provided_track.proto"; + +message AutomixTalkRules { + optional ProvidedTrack current_track = 1; + optional int64 narration_duration = 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..94ced855 --- /dev/null +++ b/protocol/proto/state_restore/behavior_metadata_rules.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..c628122f --- /dev/null +++ b/protocol/proto/state_restore/circuit_breaker_rules.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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_loader.proto b/protocol/proto/state_restore/context_loader.proto new file mode 100644 index 00000000..ba016bb3 --- /dev/null +++ b/protocol/proto/state_restore/context_loader.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "context.proto"; + +message ContextLoader { + required Context context = 1; +} + diff --git a/protocol/proto/state_restore/context_player_restorable.proto b/protocol/proto/state_restore/context_player_restorable.proto new file mode 100644 index 00000000..2f278da8 --- /dev/null +++ b/protocol/proto/state_restore/context_player_restorable.proto @@ -0,0 +1,23 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/play_history.proto"; +import "state_restore/player_model.proto"; +import "state_restore/mft_state.proto"; +import "state_restore/mft_context_history.proto"; +import "state_restore/mft_fallback_page_history.proto"; +import "state_restore/pns_capper.proto"; + +message ContextPlayerRestorable { + reserved 8; + required int32 version = 1; + optional string version_suffix = 2; + required PlayerModel player_model = 3; + required PlayHistory play_history = 4; + required MftState mft_can_play_checker = 5; + required MftContextHistory mft_context_history = 6; + required MftFallbackPageHistory mft_fallback_page_history = 7; + optional PnsCapper pns_capper = 9; +} + 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..e4a406b0 --- /dev/null +++ b/protocol/proto/state_restore/context_player_rules.proto @@ -0,0 +1,72 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/ads_rules_inject_tracks.proto"; +import "state_restore/automix_rules.proto"; +import "state_restore/automix_talk_rules.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/kitteh_box_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 PlayEvents { + required uint64 max_consecutive = 1; + required uint64 max_occurrences_in_period = 2; + required int64 period = 3; +} + +message SkipEvents { + required uint64 max_occurrences_in_period = 1; + required int64 period = 2; +} + +message Context { + required uint64 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; + optional PlayEvents social_track = 6; +} + +message MftRules { + required bool locked = 1; + optional MftConfiguration config = 2; + map old_forward_rules = 3; + optional ContextPlayerRules forward_rules = 4; +} + +message ContextPlayerRules { + optional BehaviorMetadataRules behavior_metadata_rules = 1; + optional CircuitBreakerRules circuit_breaker_rules = 2; + optional ExplicitContentRules explicit_content_rules = 3; + 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; + optional KittehBoxRules kitteh_box_rules = 12; + optional AutomixRules automix_rules = 13; + optional AutomixTalkRules automix_talk_rules = 14; + optional MftRules mft_rules = 15; + map sub_rules = 16; + optional bool is_adaptor_only = 17; +} + 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/context_player_state.proto b/protocol/proto/state_restore/context_player_state.proto new file mode 100644 index 00000000..74b66a65 --- /dev/null +++ b/protocol/proto/state_restore/context_player_state.proto @@ -0,0 +1,59 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "context_index.proto"; +import "restrictions.proto"; +import "play_origin.proto"; +import "state_restore/provided_track.proto"; +import "context_player_options.proto"; +import "prepare_play_options.proto"; +import "state_restore/playback_quality.proto"; + +message ContextPlayerState { + message ContextMetadataEntry { + optional string key = 1; + optional string value = 2; + } + + message PageMetadataEntry { + optional string key = 1; + optional string value = 2; + } + + message ModesEntry { + optional string key = 1; + optional string value = 2; + } + + optional uint64 timestamp = 1; + optional string context_uri = 2; + optional string context_url = 3; + optional Restrictions context_restrictions = 4; + optional PlayOrigin play_origin = 5; + optional ContextIndex index = 6; + optional ProvidedTrack track = 7; + optional bytes playback_id = 8; + optional PlaybackQuality playback_quality = 9; + optional double playback_speed = 10; + optional uint64 position_as_of_timestamp = 11; + optional uint64 duration = 12; + optional bool is_playing = 13; + optional bool is_paused = 14; + optional bool is_buffering = 15; + optional bool is_system_initiated = 16; + optional ContextPlayerOptions options = 17; + optional Restrictions restrictions = 18; + repeated string suppressions = 19; + repeated ProvidedTrack prev_tracks = 20; + repeated ProvidedTrack next_tracks = 21; + repeated ContextPlayerState.ContextMetadataEntry context_metadata = 22; + repeated ContextPlayerState.PageMetadataEntry page_metadata = 23; + optional string session_id = 24; + optional uint64 queue_revision = 25; + optional AudioStream audio_stream = 26; + repeated string signals = 27; + repeated ContextPlayerState.ModesEntry modes = 28; + optional string session_command_id = 29; +} + 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..8dafae43 --- /dev/null +++ b/protocol/proto/state_restore/explicit_content_rules.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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/kitteh_box_rules.proto b/protocol/proto/state_restore/kitteh_box_rules.proto new file mode 100644 index 00000000..08de7d30 --- /dev/null +++ b/protocol/proto/state_restore/kitteh_box_rules.proto @@ -0,0 +1,30 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +message KittehBoxRules { + message NodeAspectsEntry { + optional string key = 1; + optional bytes value = 2; + } + + enum Transition { + ADVANCE = 0; + SKIP_NEXT = 1; + SKIP_PREV = 2; + } + + enum Position { + BETWEEN_TRACKS = 0; + ON_DELIMITER = 1; + ON_NODE_TRACK = 2; + } + + repeated KittehBoxRules.NodeAspectsEntry node_aspects = 1; + required KittehBoxRules.Position pos = 2; + required KittehBoxRules.Transition last_transition = 3; + required int32 context_iteration = 4; + required bool pending_skip_to = 5; + optional int64 page_index = 6; +} + 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..8b9d7a30 --- /dev/null +++ b/protocol/proto/state_restore/mft_context_history.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..876d65c8 --- /dev/null +++ b/protocol/proto/state_restore/mft_context_switch_rules.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..7daca14a --- /dev/null +++ b/protocol/proto/state_restore/mft_fallback_page_history.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..8845778e --- /dev/null +++ b/protocol/proto/state_restore/mft_rules_core.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..41eafd48 --- /dev/null +++ b/protocol/proto/state_restore/mft_rules_inject_filler_tracks.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..4494ae12 --- /dev/null +++ b/protocol/proto/state_restore/mft_state.proto @@ -0,0 +1,31 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message EventList { + repeated uint64 event_times = 1; +} + +message LastEvent { + required string uri = 1; + required uint64 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; + optional EventList skip = 5; + required uint64 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..31ca96d8 --- /dev/null +++ b/protocol/proto/state_restore/mod_interruption_state.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..6faa2a57 --- /dev/null +++ b/protocol/proto/state_restore/mod_rules_interruptions.proto @@ -0,0 +1,28 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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 { + enum InterruptionSource { + CONTEXT = 1; + SAS = 2; + NO_INTERRUPTIONS = 3; + } + + 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; +} + 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..5670b521 --- /dev/null +++ b/protocol/proto/state_restore/music_injection_rules.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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/playback_state.proto b/protocol/proto/state_restore/playback_state.proto new file mode 100644 index 00000000..1b995d1a --- /dev/null +++ b/protocol/proto/state_restore/playback_state.proto @@ -0,0 +1,15 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "state_restore/playback_quality.proto"; + +message PlaybackState { + optional int64 timestamp = 1; + optional int32 position_as_of_timestamp = 2; + optional int32 duration = 3; + optional bool is_buffering = 4; + optional PlaybackQuality playback_quality = 5; + optional double playback_speed = 6; +} + diff --git a/protocol/proto/state_restore/player_model.proto b/protocol/proto/state_restore/player_model.proto new file mode 100644 index 00000000..aa08495c --- /dev/null +++ b/protocol/proto/state_restore/player_model.proto @@ -0,0 +1,38 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "context_player_options.proto"; +import "state_restore/player_session_queue.proto"; + +message PlayerModel { + message ConfigurationEntry { + optional string key = 1; + optional string value = 2; + } + + enum AdvanceReason { + SKIP_TO_PREV_TRACK = 1; + SKIP_TO_NEXT_TRACK = 2; + EXTERNAL_ADVANCE = 3; + INTERRUPTED = 4; + SWITCHED_TO_VIDEO = 5; + SWITCHED_TO_AUDIO = 6; + } + + enum StartReason { + PLAY_CONTEXT = 1; + PLAY_CONTEXT_TRACK = 2; + STATE_RESTORE = 3; + REMOTE_TRANSFER = 4; + } + + required ContextPlayerOptions options = 1; + repeated PlayerModel.ConfigurationEntry configuration = 2; + required PlayerSessionQueue session_queue = 3; + required PlayerModel.AdvanceReason last_advance_reason = 4; + optional PlayerModel.StartReason last_start_reason = 5; + required string prev_state_id = 6; + optional string override_state_id = 7; +} + diff --git a/protocol/proto/state_restore/player_session.proto b/protocol/proto/state_restore/player_session.proto new file mode 100644 index 00000000..ee5d525f --- /dev/null +++ b/protocol/proto/state_restore/player_session.proto @@ -0,0 +1,39 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "context_track.proto"; +import "context_player_options.proto"; +import "logging_params.proto"; +import "play_origin.proto"; +import "player_license.proto"; +import "prepare_play_options.proto"; +import "state_restore/context_loader.proto"; +import "state_restore/context_player_rules.proto"; +import "state_restore/playback_state.proto"; +import "state_restore/player_session_fake.proto"; +import "state_restore/provided_track.proto"; + +message PlayerSession { + required PreparePlayOptions prepare_play_options = 1; + optional PlaybackState playback_state = 2; + optional ProvidedTrack track = 3; + optional ContextTrack track_to_skip_to = 4; + optional bytes given_playback_id = 5; + required LoggingParams next_command_logging_params = 6; + required LoggingParams curr_command_logging_params = 7; + required PlayOrigin play_origin = 8; + required bool is_playing = 9; + required bool is_paused = 10; + required bool is_system_initiated = 11; + required bool is_finished = 12; + required ContextPlayerOptions options = 13; + required uint64 playback_seed = 14; + required int32 num_advances = 15; + required bool did_skip_prev = 16; + required PlayerLicense license = 17; + required ContextPlayerRules rules = 18; + required ContextLoader loader = 19; + optional PlayerSessionFake fake = 100; +} + diff --git a/protocol/proto/state_restore/player_session_fake.proto b/protocol/proto/state_restore/player_session_fake.proto new file mode 100644 index 00000000..7f405127 --- /dev/null +++ b/protocol/proto/state_restore/player_session_fake.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; + +package spotify.player.proto.state_restore; + +import "context.proto"; +import "state_restore/context_player_state.proto"; + +message PlayerSessionFake { + required ContextPlayerState player_state = 1; + required Context player_context = 2; + required bool is_finished = 3; +} + 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..036c277e --- /dev/null +++ b/protocol/proto/state_restore/player_session_queue.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +import "state_restore/player_session.proto"; + +package spotify.player.proto.state_restore; + +option optimize_for = CODE_SIZE; + +message QueuedSession { + enum Trigger { + DID_GO_PAST_TRACK = 1; + DID_GO_PAST_CONTEXT = 2; + } + + optional QueuedSession.Trigger trigger = 1; + optional PlayerSession session = 2; +} + +message PlayerSessionQueue { + optional PlayerSession active = 1; + repeated PlayerSession pushed = 2; + repeated QueuedSession queued = 3; +} + diff --git a/protocol/proto/state_restore/provided_track.proto b/protocol/proto/state_restore/provided_track.proto new file mode 100644 index 00000000..7332cb57 --- /dev/null +++ b/protocol/proto/state_restore/provided_track.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..f2739c57 --- /dev/null +++ b/protocol/proto/state_restore/random_source.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..99de63aa --- /dev/null +++ b/protocol/proto/state_restore/remove_banned_tracks_rules.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..31d35a6d --- /dev/null +++ b/protocol/proto/state_restore/resume_points_rules.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..a241d88c --- /dev/null +++ b/protocol/proto/state_restore/track_error_rules.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +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..017cc826 --- /dev/null +++ b/protocol/proto/status.proto @@ -0,0 +1,12 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.collection_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message Status { + int32 code = 1; + string reason = 2; +} diff --git a/protocol/proto/status_code.proto b/protocol/proto/status_code.proto new file mode 100644 index 00000000..08153b1e --- /dev/null +++ b/protocol/proto/status_code.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum StatusCode { + INVALID_STATUS_CODE = 0; + SUCCESS = 1; + EVENT_SENDER_ERROR = 2; + INVALID_STREAM_HANDLE = 3; + PENDING_EVENTS_ERROR = 4; + IGNORED = 5; +} diff --git a/protocol/proto/status_response.proto b/protocol/proto/status_response.proto new file mode 100644 index 00000000..27173957 --- /dev/null +++ b/protocol/proto/status_response.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "status_code.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StatusResponse { + StatusCode status_code = 1; +} diff --git a/protocol/proto/storage-resolve.proto b/protocol/proto/storage-resolve.proto new file mode 100644 index 00000000..8ccd73a8 --- /dev/null +++ b/protocol/proto/storage-resolve.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.download.proto; + +option optimize_for = CODE_SIZE; + +message StorageResolveResponse { + Result result = 1; + enum Result { + CDN = 0; + STORAGE = 1; + RESTRICTED = 3; + } + + repeated string cdnurl = 2; + bytes fileid = 4; +} diff --git a/protocol/proto/storage_cosmos.proto b/protocol/proto/storage_cosmos.proto new file mode 100644 index 00000000..97169850 --- /dev/null +++ b/protocol/proto/storage_cosmos.proto @@ -0,0 +1,18 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.storage_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message GetFileCacheRangesResponse { + bool byte_size_known = 1; + uint64 byte_size = 2; + + repeated Range ranges = 3; + message Range { + uint64 from_byte = 1; + uint64 to_byte = 2; + } +} diff --git a/protocol/proto/storylines.proto b/protocol/proto/storylines.proto new file mode 100644 index 00000000..11509454 --- /dev/null +++ b/protocol/proto/storylines.proto @@ -0,0 +1,29 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.storylines.v1; + +option java_multiple_files = true; +option java_outer_classname = "StorylinesProto"; +option java_package = "com.spotify.storylines.v1.extended_metadata"; + +message Artist { + string uri = 1; + string name = 2; + string avatar_cdn_url = 3; +} + +message Card { + string id = 1; + string image_cdn_url = 2; + int32 image_width = 3; + int32 image_height = 4; +} + +message Storyline { + string id = 1; + string entity_uri = 2; + Artist artist = 3; + repeated Card cards = 4; +} diff --git a/protocol/proto/stream_end_request.proto b/protocol/proto/stream_end_request.proto new file mode 100644 index 00000000..762d0941 --- /dev/null +++ b/protocol/proto/stream_end_request.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "stream_handle.proto"; +import "play_reason.proto"; +import "media_format.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamEndRequest { + StreamHandle stream_handle = 1; + string source_end = 2; + PlayReason reason_end = 3; + google.protobuf.Timestamp client_timestamp = 5; + optional AudioFormat format = 4; +} diff --git a/protocol/proto/stream_handle.proto b/protocol/proto/stream_handle.proto new file mode 100644 index 00000000..b293fa18 --- /dev/null +++ b/protocol/proto/stream_handle.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamHandle { + reserved 1; + uint32 raw_handle = 2; +} diff --git a/protocol/proto/stream_progress_request.proto b/protocol/proto/stream_progress_request.proto new file mode 100644 index 00000000..4c68e690 --- /dev/null +++ b/protocol/proto/stream_progress_request.proto @@ -0,0 +1,33 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "google/protobuf/timestamp.proto"; +import "audio_format.proto"; +import "stream_handle.proto"; +import "playback_state.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamProgressRequest { + StreamHandle stream_handle = 1; + uint64 current_position = 2; + bool is_paused = 3; + bool is_playing_video = 4; + bool is_overlapping = 5; + bool is_background = 6; + bool is_fullscreen = 7; + bool is_external = 8; + double playback_speed = 9; + google.protobuf.Timestamp client_timestamp = 14; + PlaybackState playback_state = 15; + optional string media_id = 10; + optional bool content_is_downloaded = 11; + optional AudioFormat audio_format = 12; + optional string content_uri = 13; + optional bool is_audio_on = 16; + optional string video_surface = 17; +} diff --git a/protocol/proto/stream_seek_request.proto b/protocol/proto/stream_seek_request.proto new file mode 100644 index 00000000..32c6ac6b --- /dev/null +++ b/protocol/proto/stream_seek_request.proto @@ -0,0 +1,20 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "google/protobuf/timestamp.proto"; +import "stream_handle.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamSeekRequest { + reserved 2; + StreamHandle stream_handle = 1; + uint64 from_position = 3; + uint64 to_position = 4; + google.protobuf.Timestamp client_timestamp = 5; + optional bool is_system_initiated = 6; +} diff --git a/protocol/proto/stream_start_request.proto b/protocol/proto/stream_start_request.proto new file mode 100644 index 00000000..3f762dd0 --- /dev/null +++ b/protocol/proto/stream_start_request.proto @@ -0,0 +1,63 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "google/protobuf/timestamp.proto"; +import "media_type.proto"; +import "play_reason.proto"; +import "playback_stack.proto"; +import "playback_stack_v2.proto"; +import "streaming_rule.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamStartRequest { + reserved 9; + reserved 10; + reserved 13; + reserved 14; + reserved 27; + reserved 25; + reserved 35; + bytes playback_id = 1; + bytes parent_playback_id = 2; + string parent_play_track = 3; + string video_session_id = 4; + string play_context = 5; + string content_uri = 6; + string displayed_content_uri = 7; + PlaybackStack playback_stack = 8; + string provider = 11; + string referrer = 12; + 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; + string media_id = 28; + MediaType media_type = 29; + uint64 playback_start_time = 30; + uint64 start_position = 31; + bool is_live = 32; + bool content_is_downloaded = 33; + bool client_offline = 34; + string feature_uuid = 36; + string decision_id = 37; + string custom_reporting_attribution = 38; + string play_context_decision_id = 39; + google.protobuf.Timestamp client_timestamp = 40; + bool is_video_on = 44; + string player_session_id = 47; + optional bool is_repeating_track = 41; + optional bool is_repeating_context = 42; + optional bool is_audio_on = 43; + optional string video_surface = 45; + optional PlaybackStackV2 playback_stack_v2 = 46; + optional string preview_impression_uri = 48; +} + diff --git a/protocol/proto/stream_start_response.proto b/protocol/proto/stream_start_response.proto new file mode 100644 index 00000000..fd1f710c --- /dev/null +++ b/protocol/proto/stream_start_response.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +import "status_response.proto"; +import "stream_handle.proto"; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +message StreamStartResponse { + StatusResponse status = 1; + StreamHandle stream_handle = 2; +} diff --git a/protocol/proto/streaming_rule.proto b/protocol/proto/streaming_rule.proto new file mode 100644 index 00000000..fbf57382 --- /dev/null +++ b/protocol/proto/streaming_rule.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.stream_reporting_esperanto.proto; + +option objc_class_prefix = "ESP"; +option java_package = "com.spotify.stream_reporting_esperanto.proto"; + +enum StreamingRule { + STREAMING_RULE_NONE = 0; + STREAMING_RULE_DMCA_RADIO = 1; + STREAMING_RULE_PREVIEW = 2; + STREAMING_RULE_WIFI = 3; + STREAMING_RULE_SHUFFLE_MODE = 4; + STREAMING_RULE_TABLET_FREE = 5; +} diff --git a/protocol/proto/suggest.proto b/protocol/proto/suggest.proto deleted file mode 100644 index ef45f1e2..00000000 --- a/protocol/proto/suggest.proto +++ /dev/null @@ -1,43 +0,0 @@ -syntax = "proto2"; - -message Track { - optional bytes gid = 0x1; - optional string name = 0x2; - optional bytes image = 0x3; - repeated string artist_name = 0x4; - repeated bytes artist_gid = 0x5; - optional uint32 rank = 0x6; -} - -message Artist { - optional bytes gid = 0x1; - optional string name = 0x2; - optional bytes image = 0x3; - optional uint32 rank = 0x6; -} - -message Album { - optional bytes gid = 0x1; - optional string name = 0x2; - optional bytes image = 0x3; - repeated string artist_name = 0x4; - repeated bytes artist_gid = 0x5; - optional uint32 rank = 0x6; -} - -message Playlist { - optional string uri = 0x1; - optional string name = 0x2; - optional string image_uri = 0x3; - optional string owner_name = 0x4; - optional string owner_uri = 0x5; - optional uint32 rank = 0x6; -} - -message Suggestions { - repeated Track track = 0x1; - repeated Album album = 0x2; - repeated Artist artist = 0x3; - repeated Playlist playlist = 0x4; -} - diff --git a/protocol/proto/suppressions.proto b/protocol/proto/suppressions.proto new file mode 100644 index 00000000..f514b03f --- /dev/null +++ b/protocol/proto/suppressions.proto @@ -0,0 +1,11 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message Suppressions { + repeated string providers = 1; +} diff --git a/protocol/proto/sync/album_sync_state.proto b/protocol/proto/sync/album_sync_state.proto new file mode 100644 index 00000000..b06a35da --- /dev/null +++ b/protocol/proto/sync/album_sync_state.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message AlbumSyncState { + optional string offline = 1; + optional string inferred_offline = 2; + optional uint32 sync_progress = 3; +} diff --git a/protocol/proto/sync/artist_sync_state.proto b/protocol/proto/sync/artist_sync_state.proto new file mode 100644 index 00000000..93f7495f --- /dev/null +++ b/protocol/proto/sync/artist_sync_state.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message ArtistSyncState { + optional string offline = 1; + optional string inferred_offline = 2; + optional uint32 sync_progress = 3; +} diff --git a/protocol/proto/sync/episode_sync_state.proto b/protocol/proto/sync/episode_sync_state.proto new file mode 100644 index 00000000..fa1511e6 --- /dev/null +++ b/protocol/proto/sync/episode_sync_state.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message EpisodeSyncState { + optional string offline_state = 1; + optional uint32 sync_progress = 2; +} diff --git a/protocol/proto/sync/track_sync_state.proto b/protocol/proto/sync/track_sync_state.proto new file mode 100644 index 00000000..f9f4be01 --- /dev/null +++ b/protocol/proto/sync/track_sync_state.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.cosmos_util.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.cosmos.util.proto"; + +message TrackSyncState { + optional string offline = 1; + optional uint32 sync_progress = 2; +} diff --git a/protocol/proto/sync_request.proto b/protocol/proto/sync_request.proto new file mode 100644 index 00000000..18d5b650 --- /dev/null +++ b/protocol/proto/sync_request.proto @@ -0,0 +1,14 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.playlist.cosmos.proto; + +option objc_class_prefix = "SPTPlaylist"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.playlist.proto"; + +message SyncRequest { + repeated string playlist_uris = 1; +} diff --git a/protocol/proto/techu_core_exercise_cosmos.proto b/protocol/proto/techu_core_exercise_cosmos.proto new file mode 100644 index 00000000..155a303f --- /dev/null +++ b/protocol/proto/techu_core_exercise_cosmos.proto @@ -0,0 +1,16 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.techu_core_exercise_cosmos.proto; + +option optimize_for = CODE_SIZE; + +message TechUCoreExerciseRequest { + string a = 1; + string b = 2; +} + +message TechUCoreExerciseResponse { + string concatenated = 1; +} diff --git a/protocol/proto/toplist.proto b/protocol/proto/toplist.proto deleted file mode 100644 index 1a12159f..00000000 --- a/protocol/proto/toplist.proto +++ /dev/null @@ -1,6 +0,0 @@ -syntax = "proto2"; - -message Toplist { - repeated string items = 0x1; -} - diff --git a/protocol/proto/track_instance.proto b/protocol/proto/track_instance.proto new file mode 100644 index 00000000..fd501bfe --- /dev/null +++ b/protocol/proto/track_instance.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "context_index.proto"; +import "context_track.proto"; +import "seek_to_position.proto"; + +option optimize_for = CODE_SIZE; + +message TrackInstance { + reserved 3; + optional ContextTrack track = 1; + optional uint64 id = 2; + optional SeekToPosition seek_to_position = 7; + optional bool initially_paused = 4; + optional ContextIndex index = 5; + optional string provider = 6; +} diff --git a/protocol/proto/track_instantiator.proto b/protocol/proto/track_instantiator.proto new file mode 100644 index 00000000..47ee739a --- /dev/null +++ b/protocol/proto/track_instantiator.proto @@ -0,0 +1,13 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto; + +option optimize_for = CODE_SIZE; + +message TrackInstantiator { + optional uint64 unique = 1; + optional uint64 count = 2; + optional string provider = 3; +} diff --git a/protocol/proto/transcripts.proto b/protocol/proto/transcripts.proto new file mode 100644 index 00000000..05ac7fbb --- /dev/null +++ b/protocol/proto/transcripts.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto3"; + +package spotify.corex.transcripts.metadata; + +option objc_class_prefix = "SPT"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_outer_classname = "TranscriptMetadataProto"; +option java_package = "com.spotify.corex.transcripts.metadata.proto"; + +message EpisodeTranscript { + string episode_uri = 1; + repeated Transcript transcripts = 2; +} + +message Transcript { + string uri = 1; + string language = 2; + bool curated = 3; + string cdn_url = 4; +} diff --git a/protocol/proto/transfer_node.proto b/protocol/proto/transfer_node.proto new file mode 100644 index 00000000..e5bbc03e --- /dev/null +++ b/protocol/proto/transfer_node.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.1.61.583 (Windows) + +syntax = "proto2"; + +package spotify.player.proto; + +import "track_instance.proto"; +import "track_instantiator.proto"; + +option optimize_for = CODE_SIZE; + +message TransferNode { + optional TrackInstance instance = 1; + optional TrackInstantiator instantiator = 2; +} diff --git a/protocol/proto/transfer_state.proto b/protocol/proto/transfer_state.proto new file mode 100644 index 00000000..a90b048e --- /dev/null +++ b/protocol/proto/transfer_state.proto @@ -0,0 +1,21 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.player.proto.transfer; + +import "context_player_options.proto"; +import "playback.proto"; +import "play_history.proto"; +import "session.proto"; +import "queue.proto"; + +option optimize_for = CODE_SIZE; + +message TransferState { + optional ContextPlayerOptions options = 1; + optional Playback playback = 2; + optional Session current_session = 3; + optional Queue queue = 4; + optional PlayHistory play_history = 5; +} diff --git a/protocol/proto/tts-resolve.proto b/protocol/proto/tts-resolve.proto new file mode 100644 index 00000000..0d0bcf02 --- /dev/null +++ b/protocol/proto/tts-resolve.proto @@ -0,0 +1,88 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.narration.proto; + +option optimize_for = CODE_SIZE; + +message ResolveRequest { + AudioFormat audio_format = 3; + enum AudioFormat { + UNSPECIFIED = 0; + WAV = 1; + PCM = 2; + OPUS = 3; + VORBIS = 4; + MP3 = 5; + } + + string language = 4; + + TtsVoice tts_voice = 5; + enum TtsVoice { + UNSET_TTS_VOICE = 0; + VOICE1 = 1; + VOICE2 = 2; + VOICE3 = 3; + VOICE4 = 4; + VOICE5 = 5; + VOICE6 = 6; + VOICE7 = 7; + VOICE8 = 8; + VOICE9 = 9; + VOICE10 = 10; + VOICE11 = 11; + VOICE12 = 12; + VOICE13 = 13; + VOICE14 = 14; + VOICE15 = 15; + VOICE16 = 16; + VOICE17 = 17; + VOICE18 = 18; + VOICE19 = 19; + VOICE20 = 20; + VOICE21 = 21; + VOICE22 = 22; + VOICE23 = 23; + VOICE24 = 24; + VOICE25 = 25; + VOICE26 = 26; + VOICE27 = 27; + VOICE28 = 28; + VOICE29 = 29; + VOICE30 = 30; + VOICE31 = 31; + VOICE32 = 32; + VOICE33 = 33; + VOICE34 = 34; + VOICE35 = 35; + VOICE36 = 36; + VOICE37 = 37; + VOICE38 = 38; + VOICE39 = 39; + VOICE40 = 40; + } + + TtsProvider tts_provider = 6; + enum TtsProvider { + UNSET_TTS_PROVIDER = 0; + CLOUD_TTS = 1; + READSPEAKER = 2; + POLLY = 3; + WELL_SAID = 4; + SONANTIC_DEPRECATED = 5; + SONANTIC_FAST = 6; + } + + int32 sample_rate_hz = 7; + oneof prompt { + string text = 1; + string ssml = 2; + } +} + +message ResolveResponse { + string url = 1; + int64 expiry = 2; +} diff --git a/protocol/proto/ucs.proto b/protocol/proto/ucs.proto new file mode 100644 index 00000000..d3c82997 --- /dev/null +++ b/protocol/proto/ucs.proto @@ -0,0 +1,53 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.remote_config.ucs.proto; + +import "resolve.proto"; +import "useraccount.proto"; + +option optimize_for = CODE_SIZE; + +message UcsRequest { + CallerInfo caller_info = 1; + message CallerInfo { + string request_origin_id = 1; + string request_orgin_version = 2; + string reason = 3; + } + + ResolveRequest resolve_request = 2; + + AccountAttributesRequest account_attributes_request = 3; + message AccountAttributesRequest { + } +} + +message UcsResponseWrapper { + oneof result { + UcsResponse success = 1; + Error error = 2; + } + + message UcsResponse { + int64 fetch_time_millis = 5; + oneof resolve_result { + ResolveResponse resolve_success = 1; + Error resolve_error = 2; + } + oneof account_attributes_result { + AccountAttributesResponse account_attributes_success = 3; + Error account_attributes_error = 4; + } + } + + message AccountAttributesResponse { + map account_attributes = 1; + } + + message Error { + int32 error_code = 1; + string error_message = 2; + } +} diff --git a/protocol/proto/unfinished_episodes_request.proto b/protocol/proto/unfinished_episodes_request.proto new file mode 100644 index 00000000..ffeb703b --- /dev/null +++ b/protocol/proto/unfinished_episodes_request.proto @@ -0,0 +1,26 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto2"; + +package spotify.show_cosmos.unfinished_episodes_request.proto; + +import "metadata/episode_metadata.proto"; +import "played_state/episode_played_state.proto"; +import "show_episode_state.proto"; + +option objc_class_prefix = "SPTShowCosmosUnfinshedEpisodes"; +option optimize_for = CODE_SIZE; + +message Episode { + optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1; + optional show_cosmos.proto.EpisodeCollectionState episode_collection_state = 2; + optional show_cosmos.proto.EpisodeOfflineState episode_offline_state = 3; + optional cosmos_util.proto.EpisodePlayState episode_play_state = 4; + optional string link = 5; +} + +message Response { + repeated Episode episode = 2; + + reserved 1; +} diff --git a/protocol/proto/user_attributes.proto b/protocol/proto/user_attributes.proto new file mode 100644 index 00000000..96ecf010 --- /dev/null +++ b/protocol/proto/user_attributes.proto @@ -0,0 +1,29 @@ +// Custom protobuf crafted from spotify:user:attributes:mutated response: +// +// 1 { +// 1: "filter-explicit-content" +// } +// 2 { +// 1: 1639087299 +// 2: 418909000 +// } + +syntax = "proto3"; + +package spotify.user_attributes.proto; + +option optimize_for = CODE_SIZE; + +message UserAttributesMutation { + repeated MutatedField fields = 1; + MutationCommand cmd = 2; +} + +message MutatedField { + string name = 1; +} + +message MutationCommand { + int64 timestamp = 1; + int32 unknown = 2; +} diff --git a/protocol/proto/useraccount.proto b/protocol/proto/useraccount.proto new file mode 100644 index 00000000..fd73fe02 --- /dev/null +++ b/protocol/proto/useraccount.proto @@ -0,0 +1,15 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.remote_config.ucs.proto; + +option optimize_for = CODE_SIZE; + +message AccountAttribute { + oneof value { + bool bool_value = 2; + int64 long_value = 3; + string string_value = 4; + } +} diff --git a/protocol/proto/your_library_config.proto b/protocol/proto/your_library_config.proto new file mode 100644 index 00000000..3030f34b --- /dev/null +++ b/protocol/proto/your_library_config.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package spotify.your_library.proto; + +message YourLibraryLabelAndImage { + string label = 1; + string image = 2; + bool include_empty = 3; +} + +message YourLibraryPseudoPlaylistConfig { + .spotify.your_library.proto.YourLibraryLabelAndImage liked_songs = 1; + .spotify.your_library.proto.YourLibraryLabelAndImage your_episodes = 2; + .spotify.your_library.proto.YourLibraryLabelAndImage new_episodes = 3; + .spotify.your_library.proto.YourLibraryLabelAndImage local_files = 4; + .spotify.your_library.proto.YourLibraryLabelAndImage cached_files = 5; + bool your_highlights = 6; + bool all_available_configs_provided = 99; +} + +message YourLibraryFilters { + enum Filter { + ALBUM = 0; + ARTIST = 1; + PLAYLIST = 2; + SHOW = 3; + BOOK = 4; + EVENT = 5; + AUTHOR = 7; + DOWNLOADED = 100; + WRITABLE = 101; + BY_YOU = 102; + BY_SPOTIFY = 103; + UNPLAYED = 104; + IN_PROGRESS = 105; + FINISHED = 106; + } + + repeated .spotify.your_library.proto.YourLibraryFilters.Filter filter = 1; +} + +message YourLibrarySortOrder { + enum SortOrder { + NAME = 0; + RECENTLY_ADDED = 1; + CREATOR = 2; + CUSTOM = 4; + RECENTLY_UPDATED = 5; + RECENTLY_PLAYED_OR_ADDED = 6; + RELEVANCE = 7; + EVENT_START_TIME = 8; + RELEASE_DATE = 9; + } + + .spotify.your_library.proto.YourLibrarySortOrder.SortOrder sort_order = 1; +} + diff --git a/protocol/proto/your_library_contains_request.proto b/protocol/proto/your_library_contains_request.proto new file mode 100644 index 00000000..ea1f746d --- /dev/null +++ b/protocol/proto/your_library_contains_request.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_config.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.your_library.esperanto.proto"; + +message YourLibraryContainsRequest { + repeated string requested_uri = 3; + YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 4; + int32 update_throttling = 5; +} diff --git a/protocol/proto/your_library_contains_response.proto b/protocol/proto/your_library_contains_response.proto new file mode 100644 index 00000000..232f9939 --- /dev/null +++ b/protocol/proto/your_library_contains_response.proto @@ -0,0 +1,25 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.your_library.esperanto.proto"; + +message YourLibraryContainsResponseHeader { + bool is_loading = 2; +} + +message YourLibraryContainsResponseEntity { + string uri = 1; + bool is_in_library = 2; +} + +message YourLibraryContainsResponse { + YourLibraryContainsResponseHeader header = 1; + repeated YourLibraryContainsResponseEntity entity = 2; + uint32 status_code = 98; + string error = 99; +} diff --git a/protocol/proto/your_library_decorate_request.proto b/protocol/proto/your_library_decorate_request.proto new file mode 100644 index 00000000..9c35b1df --- /dev/null +++ b/protocol/proto/your_library_decorate_request.proto @@ -0,0 +1,17 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_config.proto"; + +option java_package = "spotify.your_library.esperanto.proto"; +option java_multiple_files = true; +option optimize_for = CODE_SIZE; + +message YourLibraryDecorateRequest { + repeated string requested_uri = 3; + YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 6; + int32 update_throttling = 7; +} diff --git a/protocol/proto/your_library_decorate_response.proto b/protocol/proto/your_library_decorate_response.proto new file mode 100644 index 00000000..b6896df2 --- /dev/null +++ b/protocol/proto/your_library_decorate_response.proto @@ -0,0 +1,22 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_decorated_entity.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.your_library.esperanto.proto"; + +message YourLibraryDecorateResponseHeader { + bool is_loading = 2; +} + +message YourLibraryDecorateResponse { + YourLibraryDecorateResponseHeader header = 1; + repeated YourLibraryDecoratedEntity entity = 2; + uint32 status_code = 98; + 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..848ab09e --- /dev/null +++ b/protocol/proto/your_library_decorated_entity.proto @@ -0,0 +1,179 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "policy/supported_link_types_in_playlists.proto"; + +option optimize_for = CODE_SIZE; + +message YourLibraryEntityInfo { + enum Pinnable { + YES = 0; + NO_IN_FOLDER = 1; + } + + string name = 2; + string uri = 3; + string group_label = 5; + string image_uri = 6; + bool pinned = 7; + Pinnable pinnable = 8; + Offline.Availability offline_availability = 9; + int64 add_time = 11; + int64 last_played = 12; + bool has_curated_items = 13; +} + +message Offline { + enum Availability { + UNKNOWN = 0; + NO = 1; + YES = 2; + DOWNLOADING = 3; + WAITING = 4; + } + +} + +message YourLibraryAlbumExtraInfo { + enum Type { + ALBUM = 0; + SINGLE = 1; + COMPILATION = 2; + EP = 3; + } + + string artist_name = 1; + string artist_uri = 2; + Type type = 3; + bool is_premium_only = 4; + bool new_release = 5; +} + +message YourLibraryArtistExtraInfo { + bool has_liked_tracks_or_albums = 1; +} + +message NumberOfItemsForLinkType { + playlist.cosmos.proto.LinkType link_type = 1; + int32 num_items = 2; +} + +message YourLibraryPlaylistFolderInfo { + string uri = 1; + string name = 2; +} + +message YourLibraryPlaylistExtraInfo { + string creator_name = 1; + string creator_uri = 8; + bool is_loading = 5; + bool can_view = 6; + bool can_add = 9; + string row_id = 7; + string made_for_name = 10; + string made_for_uri = 11; + repeated NumberOfItemsForLinkType number_of_items_per_link_type = 12; + bool owned_by_self = 13; + YourLibraryPlaylistFolderInfo from_folder = 14; + string name_prefix = 15; +} + +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; + string row_id = 4; + repeated YourLibraryDecoratedEntity entity = 5; +} + +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 { + enum Access { + OPEN = 0; + LOCKED = 1; + CAPPED = 2; + } + + enum State { + NOT_STARTED = 0; + IN_PROGRESS = 1; + FINISHED = 2; + } + + string author_name = 1; + Access access = 2; + int64 milliseconds_left = 3; + int32 percent_done = 4; + State state = 5; +} + +message YourLibraryCachedFilesExtraInfo { + int32 number_of_items = 1; + int32 duration_in_seconds = 2; +} + +message YourLibraryPreReleaseExtraInfo { + enum Type { + ALBUM = 0; + BOOK = 1; + } + + string artist_name = 1; + string artist_uri = 2; + Type type = 3; + YourLibraryAlbumExtraInfo.Type album_type = 4; +} + +message YourLibraryEventExtraInfo { + string location_name = 1; + int64 start_time = 2; + string city_name = 3; +} + +message YourLibraryAuthorExtraInfo { +} + +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; + YourLibraryCachedFilesExtraInfo cached_files = 13; + YourLibraryPreReleaseExtraInfo prerelease = 15; + YourLibraryEventExtraInfo event = 16; + YourLibraryAuthorExtraInfo author = 17; + } +} + diff --git a/protocol/proto/your_library_entity.proto b/protocol/proto/your_library_entity.proto new file mode 100644 index 00000000..174136ec --- /dev/null +++ b/protocol/proto/your_library_entity.proto @@ -0,0 +1,23 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_index.proto"; +import "collection_index.proto"; + +option optimize_for = CODE_SIZE; + +message YourLibraryEntity { + oneof entity { + collection.proto.CollectionAlbumEntry album = 1; + collection.proto.CollectionArtistEntry artist = 2; + YourLibraryRootlistEntity rootlist_entity = 3; + collection.proto.CollectionShowEntry show = 4; + collection.proto.CollectionBookEntry book = 5; + YourLibraryPreReleaseEntity prerelease = 6; + YourLibraryEventEntity event = 7; + collection.proto.CollectionAuthorEntry author = 9; + } +} diff --git a/protocol/proto/your_library_index.proto b/protocol/proto/your_library_index.proto new file mode 100644 index 00000000..68ada3d2 --- /dev/null +++ b/protocol/proto/your_library_index.proto @@ -0,0 +1,98 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option optimize_for = CODE_SIZE; + +message YourLibraryRootlistPlaylist { + string prefix = 1; + string image_uri = 2; + string creator_uri = 3; + string made_for_name = 4; + string made_for_uri = 5; + bool is_loading = 6; + int32 rootlist_index = 7; + string row_id = 8; + bool can_view = 9; + bool can_add = 10; + bool owned_by_self = 11; +} + +message YourLibraryPredefinedPlaylist { + string prefix = 1; + string image_uri = 2; + string creator_uri = 3; + string made_for_name = 4; + string made_for_uri = 5; + bool is_loading = 6; + bool can_view = 7; + bool can_add = 8; + bool owned_by_self = 9; +} + +message YourLibraryRootlistFolder { + int32 number_of_playlists = 1; + int32 number_of_folders = 2; + int32 rootlist_index = 3; + string row_id = 4; +} + +message YourLibraryRootlistPseudoPlaylist { + enum Kind { + LIKED_SONGS = 0; + YOUR_EPISODES = 1; + NEW_EPISODES = 2; + LOCAL_FILES = 3; + CACHED_FILES = 4; + CONTENT_FEED = 5; + YOUR_HIGHLIGHTS = 6; + } + + Kind kind = 1; +} + +message YourLibraryRootlistEntity { + string uri = 1; + string name = 2; + string creator_name = 3; + int64 add_time = 4; + int64 last_played = 5; + oneof entity { + YourLibraryRootlistPlaylist playlist = 6; + YourLibraryRootlistFolder folder = 7; + YourLibraryRootlistPseudoPlaylist pseudo_playlist = 8; + YourLibraryPredefinedPlaylist predefined_playlist = 9; + } +} + +message YourLibraryPreReleaseEntity { + enum Type { + ALBUM = 0; + BOOK = 1; + } + + string entity_name = 1; + string uri = 2; + string creator_name = 3; + string creator_uri = 4; + string image_uri = 5; + int64 add_time = 6; + int64 release_time = 9; + Type type = 7; + string type_str = 8; +} + +message YourLibraryEventEntity { + string uri = 1; + string event_name = 2; + repeated string artist_names = 3; + string location_name = 4; + string image_uri = 5; + int64 add_time = 6; + int64 event_time = 7; + int64 utc_event_time = 8; + string city_name = 9; +} + diff --git a/protocol/proto/your_library_pseudo_playlist_config.proto b/protocol/proto/your_library_pseudo_playlist_config.proto new file mode 100644 index 00000000..77c9bb53 --- /dev/null +++ b/protocol/proto/your_library_pseudo_playlist_config.proto @@ -0,0 +1,19 @@ +// Extracted from: Spotify 1.1.73.517 (macOS) + +syntax = "proto3"; + +package spotify.your_library.proto; + +option optimize_for = CODE_SIZE; + +message YourLibraryLabelAndImage { + string label = 1; + string image = 2; +} + +message YourLibraryPseudoPlaylistConfig { + YourLibraryLabelAndImage liked_songs = 1; + YourLibraryLabelAndImage your_episodes = 2; + YourLibraryLabelAndImage new_episodes = 3; + YourLibraryLabelAndImage local_files = 4; +} diff --git a/protocol/proto/your_library_request.proto b/protocol/proto/your_library_request.proto new file mode 100644 index 00000000..bbbdd5d8 --- /dev/null +++ b/protocol/proto/your_library_request.proto @@ -0,0 +1,59 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_config.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.your_library.esperanto.proto"; + +message YourLibraryTagFilter { + string tag_uri = 1; +} + +message CuratedItems { + enum CuratedItemsFilter { + NONE = 0; + GROUP_BY = 1; + ONLY_CURATED = 2; + ONLY_NOT_CURATED = 3; + } + + repeated string items = 1; + CuratedItemsFilter filter = 2; +} + +message YourLibraryRequestHeader { + bool remaining_entities = 9; + bool total_count = 18; + string lower_bound = 10; + int32 skip = 11; + int32 length = 12; + string text_filter = 13; + YourLibraryFilters filters = 14; + YourLibrarySortOrder sort_order = 15; + bool all_playlists = 17; + repeated int64 fill_folders = 34; + bool separate_pinned_items = 22; + bool num_link_types_in_playlists = 25; + bool ignore_pinning = 26; + CuratedItems curated_items = 29; + bool include_events = 30; + bool include_prereleases = 31; + bool include_authors = 33; + oneof maybe_folder_id { + int64 folder_id = 16; + } + oneof maybe_tag_filter { + .spotify.your_library.proto.YourLibraryTagFilter tag_filter = 24; + } +} + +message YourLibraryRequest { + YourLibraryRequestHeader header = 1; + YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 4; + int32 update_throttling = 5; +} diff --git a/protocol/proto/your_library_response.proto b/protocol/proto/your_library_response.proto new file mode 100644 index 00000000..3297d7d4 --- /dev/null +++ b/protocol/proto/your_library_response.proto @@ -0,0 +1,48 @@ +// Extracted from: Spotify 1.2.52.442 (windows) + +syntax = "proto3"; + +package spotify.your_library.proto; + +import "your_library_decorated_entity.proto"; +import "your_library_config.proto"; + +option java_multiple_files = true; +option optimize_for = CODE_SIZE; +option java_package = "spotify.your_library.esperanto.proto"; + +message YourLibraryTagPlaylist { + string name = 1; + string uri = 2; + string description = 3; + string image_uri = 4; + proto.Offline.Availability offline_availability = 5; + bool is_curated = 6; + bool is_loading = 7; +} + +message YourLibraryTagInfo { + string tag_name = 1; + bool is_added = 5; + YourLibraryTagPlaylist tag_playlist_info = 7; +} + +message YourLibraryResponseHeader { + int32 remaining_entities = 9; + int32 total_count = 17; + int32 pin_count = 18; + int32 maximum_pinned_items = 19; + bool is_loading = 12; + string folder_name = 15; + string parent_folder_uri = 20; + YourLibraryFilters available_filters = 16; + YourLibraryTagInfo tag_info = 21; +} + +message YourLibraryResponse { + YourLibraryResponseHeader header = 1; + repeated YourLibraryDecoratedEntity entity = 2; + repeated YourLibraryDecoratedEntity pinned_entity = 3; + int32 status_code = 98; + string error = 99; +} diff --git a/protocol/src/impl_trait.rs b/protocol/src/impl_trait.rs new file mode 100644 index 00000000..c936a5f0 --- /dev/null +++ b/protocol/src/impl_trait.rs @@ -0,0 +1,2 @@ +mod context; +mod player; diff --git a/protocol/src/impl_trait/context.rs b/protocol/src/impl_trait/context.rs new file mode 100644 index 00000000..782a41dc --- /dev/null +++ b/protocol/src/impl_trait/context.rs @@ -0,0 +1,37 @@ +use crate::{context::Context, context_page::ContextPage, context_track::ContextTrack}; +use protobuf::Message; +use std::hash::{Hash, Hasher}; + +impl Hash for Context { + fn hash(&self, state: &mut H) { + if let Ok(ctx) = self.write_to_bytes() { + ctx.hash(state) + } + } +} + +impl Eq for Context {} + +impl From> for ContextPage { + fn from(value: Vec) -> Self { + ContextPage { + tracks: value + .into_iter() + .map(|uri| ContextTrack { + uri: Some(uri), + ..Default::default() + }) + .collect(), + ..Default::default() + } + } +} + +impl From> for ContextPage { + fn from(tracks: Vec) -> Self { + ContextPage { + tracks, + ..Default::default() + } + } +} diff --git a/protocol/src/impl_trait/player.rs b/protocol/src/impl_trait/player.rs new file mode 100644 index 00000000..13286e7f --- /dev/null +++ b/protocol/src/impl_trait/player.rs @@ -0,0 +1,173 @@ +use crate::{ + context_player_options::ContextPlayerOptions, + play_origin::PlayOrigin, + player::{ + ContextPlayerOptions as PlayerContextPlayerOptions, + ModeRestrictions as PlayerModeRestrictions, PlayOrigin as PlayerPlayOrigin, + RestrictionReasons as PlayerRestrictionReasons, Restrictions as PlayerRestrictions, + Suppressions as PlayerSuppressions, + }, + restrictions::{ModeRestrictions, RestrictionReasons, Restrictions}, + suppressions::Suppressions, +}; +use std::collections::HashMap; + +fn hashmap_into, V>(map: HashMap) -> HashMap { + map.into_iter().map(|(k, v)| (k, v.into())).collect() +} + +impl From for PlayerContextPlayerOptions { + fn from(value: ContextPlayerOptions) -> Self { + PlayerContextPlayerOptions { + shuffling_context: value.shuffling_context.unwrap_or_default(), + repeating_context: value.repeating_context.unwrap_or_default(), + repeating_track: value.repeating_track.unwrap_or_default(), + modes: value.modes, + playback_speed: value.playback_speed, + special_fields: value.special_fields, + } + } +} + +impl From for Restrictions { + fn from(value: PlayerRestrictions) -> Self { + Restrictions { + disallow_pausing_reasons: value.disallow_pausing_reasons, + disallow_resuming_reasons: value.disallow_resuming_reasons, + disallow_seeking_reasons: value.disallow_seeking_reasons, + disallow_peeking_prev_reasons: value.disallow_peeking_prev_reasons, + disallow_peeking_next_reasons: value.disallow_peeking_next_reasons, + disallow_skipping_prev_reasons: value.disallow_skipping_prev_reasons, + disallow_skipping_next_reasons: value.disallow_skipping_next_reasons, + disallow_toggling_repeat_context_reasons: value + .disallow_toggling_repeat_context_reasons, + disallow_toggling_repeat_track_reasons: value.disallow_toggling_repeat_track_reasons, + disallow_toggling_shuffle_reasons: value.disallow_toggling_shuffle_reasons, + disallow_set_queue_reasons: value.disallow_set_queue_reasons, + disallow_interrupting_playback_reasons: value.disallow_interrupting_playback_reasons, + disallow_transferring_playback_reasons: value.disallow_transferring_playback_reasons, + disallow_remote_control_reasons: value.disallow_remote_control_reasons, + disallow_inserting_into_next_tracks_reasons: value + .disallow_inserting_into_next_tracks_reasons, + disallow_inserting_into_context_tracks_reasons: value + .disallow_inserting_into_context_tracks_reasons, + disallow_reordering_in_next_tracks_reasons: value + .disallow_reordering_in_next_tracks_reasons, + disallow_reordering_in_context_tracks_reasons: value + .disallow_reordering_in_context_tracks_reasons, + disallow_removing_from_next_tracks_reasons: value + .disallow_removing_from_next_tracks_reasons, + disallow_removing_from_context_tracks_reasons: value + .disallow_removing_from_context_tracks_reasons, + disallow_updating_context_reasons: value.disallow_updating_context_reasons, + disallow_add_to_queue_reasons: value.disallow_add_to_queue_reasons, + disallow_setting_playback_speed: value.disallow_setting_playback_speed_reasons, + disallow_setting_modes: hashmap_into(value.disallow_setting_modes), + disallow_signals: hashmap_into(value.disallow_signals), + special_fields: value.special_fields, + } + } +} + +impl From for PlayerRestrictions { + fn from(value: Restrictions) -> Self { + PlayerRestrictions { + disallow_pausing_reasons: value.disallow_pausing_reasons, + disallow_resuming_reasons: value.disallow_resuming_reasons, + disallow_seeking_reasons: value.disallow_seeking_reasons, + disallow_peeking_prev_reasons: value.disallow_peeking_prev_reasons, + disallow_peeking_next_reasons: value.disallow_peeking_next_reasons, + disallow_skipping_prev_reasons: value.disallow_skipping_prev_reasons, + disallow_skipping_next_reasons: value.disallow_skipping_next_reasons, + disallow_toggling_repeat_context_reasons: value + .disallow_toggling_repeat_context_reasons, + disallow_toggling_repeat_track_reasons: value.disallow_toggling_repeat_track_reasons, + disallow_toggling_shuffle_reasons: value.disallow_toggling_shuffle_reasons, + disallow_set_queue_reasons: value.disallow_set_queue_reasons, + disallow_interrupting_playback_reasons: value.disallow_interrupting_playback_reasons, + disallow_transferring_playback_reasons: value.disallow_transferring_playback_reasons, + disallow_remote_control_reasons: value.disallow_remote_control_reasons, + disallow_inserting_into_next_tracks_reasons: value + .disallow_inserting_into_next_tracks_reasons, + disallow_inserting_into_context_tracks_reasons: value + .disallow_inserting_into_context_tracks_reasons, + disallow_reordering_in_next_tracks_reasons: value + .disallow_reordering_in_next_tracks_reasons, + disallow_reordering_in_context_tracks_reasons: value + .disallow_reordering_in_context_tracks_reasons, + disallow_removing_from_next_tracks_reasons: value + .disallow_removing_from_next_tracks_reasons, + disallow_removing_from_context_tracks_reasons: value + .disallow_removing_from_context_tracks_reasons, + disallow_updating_context_reasons: value.disallow_updating_context_reasons, + disallow_add_to_queue_reasons: value.disallow_add_to_queue_reasons, + disallow_setting_playback_speed_reasons: value.disallow_setting_playback_speed, + disallow_setting_modes: hashmap_into(value.disallow_setting_modes), + disallow_signals: hashmap_into(value.disallow_signals), + disallow_playing_reasons: vec![], + disallow_stopping_reasons: vec![], + special_fields: value.special_fields, + } + } +} + +impl From for ModeRestrictions { + fn from(value: PlayerModeRestrictions) -> Self { + ModeRestrictions { + values: hashmap_into(value.values), + special_fields: value.special_fields, + } + } +} + +impl From for PlayerModeRestrictions { + fn from(value: ModeRestrictions) -> Self { + PlayerModeRestrictions { + values: hashmap_into(value.values), + special_fields: value.special_fields, + } + } +} + +impl From for RestrictionReasons { + fn from(value: PlayerRestrictionReasons) -> Self { + RestrictionReasons { + reasons: value.reasons, + special_fields: value.special_fields, + } + } +} + +impl From for PlayerRestrictionReasons { + fn from(value: RestrictionReasons) -> Self { + PlayerRestrictionReasons { + reasons: value.reasons, + special_fields: value.special_fields, + } + } +} + +impl From for PlayerPlayOrigin { + fn from(value: PlayOrigin) -> Self { + PlayerPlayOrigin { + feature_identifier: value.feature_identifier.unwrap_or_default(), + feature_version: value.feature_version.unwrap_or_default(), + view_uri: value.view_uri.unwrap_or_default(), + external_referrer: value.external_referrer.unwrap_or_default(), + referrer_identifier: value.referrer_identifier.unwrap_or_default(), + device_identifier: value.device_identifier.unwrap_or_default(), + feature_classes: value.feature_classes, + restriction_identifier: value.restriction_identifier.unwrap_or_default(), + special_fields: value.special_fields, + } + } +} + +impl From for PlayerSuppressions { + fn from(value: Suppressions) -> Self { + PlayerSuppressions { + providers: value.providers, + special_fields: value.special_fields, + } + } +} diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index 94180d54..c905ceb8 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -1,5 +1,6 @@ -extern crate protobuf; // This file is parsed by build.rs // Each included module will be compiled from the matching .proto definition. +mod impl_trait; + include!(concat!(env!("OUT_DIR"), "/mod.rs")); diff --git a/publish.sh b/publish.sh index fb4a475a..94eedf25 100755 --- a/publish.sh +++ b/publish.sh @@ -6,7 +6,23 @@ DRY_RUN='false' WORKINGDIR="$( cd "$(dirname "$0")" ; pwd -P )" cd $WORKINGDIR -crates=( "protocol" "core" "discovery" "audio" "metadata" "playback" "connect" "librespot" ) +# Order: dependencies first (so "librespot" using everything before it goes last) +crates=( "protocol" "oauth" "core" "discovery" "audio" "metadata" "playback" "connect" "librespot" ) + +function replace_in_file() { + OS=`uname` + shopt -s nocasematch + case "$OS" in + darwin) + # for macOS + sed -i '' -e "$1" "$2" + ;; + *) + # for Linux and Windows + sed -i'' -e "$1" "$2" + ;; + esac +} function switchBranch { if [ "$SKIP_MERGE" = 'false' ] ; then @@ -27,14 +43,17 @@ function updateVersion { do if [ "$CRATE" = "librespot" ] then - CRATE='' + CRATE_DIR='' + else + CRATE_DIR=$CRATE fi - crate_path="$WORKINGDIR/$CRATE/Cargo.toml" + crate_path="$WORKINGDIR/$CRATE_DIR/Cargo.toml" crate_path=${crate_path//\/\///} - sed -i '' "s/^version.*/version = \"$1\"/g" "$crate_path" + $(replace_in_file "s/^version =.*/version = \"$1\"/g" "$crate_path") echo "Path is $crate_path" if [ "$CRATE" = "librespot" ] then + echo "Updating lockfile" if [ "$DRY_RUN" = 'true' ] ; then cargo update --dry-run git add . && git commit --dry-run -a -m "Update Cargo.lock" @@ -60,20 +79,6 @@ function get_crate_name { awk -v FS="name = " 'NF>1{print $2; exit}' Cargo.toml } -function remoteWait() { - IFS=: - secs=${1} - crate_name=${2} - while [ $secs -gt 0 ] - do - sleep 1 & - printf "\rSleeping to allow %s to propagate on crates.io servers. Continuing in %2d second(s)." ${crate_name} ${secs} - secs=$(( $secs - 1 )) - wait - done - echo -} - function publishCrates { for CRATE in "${crates[@]}" do @@ -104,7 +109,6 @@ function publishCrates { fi fi echo "Successfully published $crate_name to crates.io" - remoteWait 30 $crate_name done } diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..367cc1fc --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +components = ["rustfmt", "clippy"] diff --git a/rustfmt.toml b/rustfmt.toml index aefd6aa8..f3e454b6 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1,2 @@ -# max_width = 105 -reorder_imports = true -reorder_modules = true -edition = "2018" +edition = "2024" +style_edition = "2024" diff --git a/src/lib.rs b/src/lib.rs index 75211282..f6a17654 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,6 @@ pub use librespot_connect as connect; pub use librespot_core as core; pub use librespot_discovery as discovery; pub use librespot_metadata as metadata; +pub use librespot_oauth as oauth; pub use librespot_playback as playback; pub use librespot_protocol as protocol; diff --git a/src/main.rs b/src/main.rs index a3522e8c..b824ea94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,50 +1,59 @@ -use futures_util::{future, FutureExt, StreamExt}; -use librespot_playback::player::PlayerEvent; -use log::{error, info, 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::playback::dither; +use data_encoding::HEXLOWER; +use futures_util::StreamExt; #[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}; +use librespot::{ + connect::{ConnectConfig, Spirc}, + core::{ + Session, SessionConfig, authentication::Credentials, cache::Cache, config::DeviceType, + version, + }, + discovery::DnsSdServiceBuilder, + playback::{ + audio_backend::{self, BACKENDS, SinkBuilder}, + config::{ + AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl, + }, + dither, + mixer::{self, MixerConfig, MixerFn}, + player::{Player, coefficient_to_duration, duration_to_coefficient}, + }, +}; +use librespot_oauth::OAuthClientBuilder; +use log::{debug, error, info, trace, warn}; +use sha1::{Digest, Sha1}; +use std::{ + env, + ffi::OsStr, + fs::create_dir_all, + ops::RangeInclusive, + path::{Path, PathBuf}, + pin::Pin, + process::exit, + str::FromStr, + time::{Duration, Instant}, +}; +use sysinfo::{ProcessesToUpdate, System}; +use thiserror::Error; +use tokio::sync::Semaphore; +use url::Url; 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; +use player_event_handler::{EventHandler, run_program_on_sink_events}; fn device_id(name: &str) -> String { - hex::encode(Sha1::digest(name.as_bytes())) + HEXLOWER.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!("{version}\n\n{desc}\n\n{repo_home}\n\nUsage: {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,53 +62,40 @@ 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); + println!("- {name} (default)"); } else { - println!("- {}", name); + println!("- {name}"); } } } -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")] @@ -176,6 +172,37 @@ fn get_version_string() -> String { ) } +/// Spotify's Desktop app uses these. Some of these are only available when requested with Spotify's client IDs. +static OAUTH_SCOPES: &[&str] = &[ + //const OAUTH_SCOPES: Vec<&str> = vec![ + "app-remote-control", + "playlist-modify", + "playlist-modify-private", + "playlist-modify-public", + "playlist-read", + "playlist-read-collaborative", + "playlist-read-private", + "streaming", + "ugc-image-upload", + "user-follow-modify", + "user-follow-read", + "user-library-modify", + "user-library-read", + "user-modify", + "user-modify-playback-state", + "user-modify-private", + "user-personalized", + "user-read-birthdate", + "user-read-currently-playing", + "user-read-email", + "user-read-play-history", + "user-read-playback-position", + "user-read-playback-state", + "user-read-private", + "user-read-recently-played", + "user-top-read", +]; + struct Setup { format: AudioFormat, backend: SinkBuilder, @@ -187,29 +214,44 @@ struct Setup { connect_config: ConnectConfig, mixer_config: MixerConfig, credentials: Option, - enable_discovery: bool, + enable_oauth: bool, + oauth_port: Option, zeroconf_port: u16, player_event_program: Option, emit_sink_events: bool, + zeroconf_ip: Vec, + zeroconf_backend: Option, } -fn get_setup(args: &[String]) -> Setup { +async 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..=10.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 ACCESS_TOKEN: &str = "access-token"; 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 DEVICE_IS_GROUP: &str = "group"; 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_OAUTH: &str = "enable-oauth"; 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_TYPE: &str = "mixer"; const ALSA_MIXER_DEVICE: &str = "alsa-mixer-device"; @@ -223,369 +265,902 @@ fn get_setup(args: &[String]) -> Setup { const NORMALISATION_PREGAIN: &str = "normalisation-pregain"; const NORMALISATION_RELEASE: &str = "normalisation-release"; const NORMALISATION_THRESHOLD: &str = "normalisation-threshold"; + const OAUTH_PORT: &str = "oauth-port"; const ONEVENT: &str = "onevent"; + #[cfg(feature = "passthrough-decoder")] const PASSTHROUGH: &str = "passthrough"; const PASSWORD: &str = "password"; const PROXY: &str = "proxy"; + const QUIET: &str = "quiet"; const SYSTEM_CACHE: &str = "system-cache"; + const TEMP_DIR: &str = "tmp"; const USERNAME: &str = "username"; const VERBOSE: &str = "verbose"; const VERSION: &str = "version"; const VOLUME_CTRL: &str = "volume-ctrl"; const VOLUME_RANGE: &str = "volume-range"; + const VOLUME_STEPS: &str = "volume-steps"; const ZEROCONF_PORT: &str = "zeroconf-port"; + const ZEROCONF_INTERFACE: &str = "zeroconf-interface"; + const ZEROCONF_BACKEND: &str = "zeroconf-backend"; + + // Mostly arbitrary. + const AP_PORT_SHORT: &str = "a"; + const AUTOPLAY_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 VOLUME_STEPS_SHORT: &str = ""; // no short flag + 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 ZEROCONF_INTERFACE_SHORT: &str = "i"; + const ENABLE_OAUTH_SHORT: &str = "j"; + const OAUTH_PORT_SHORT: &str = "K"; + const ACCESS_TOKEN_SHORT: &str = "k"; + 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"; + #[cfg(feature = "passthrough-decoder")] + 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"; + const ZEROCONF_BACKEND_SHORT: &str = ""; // no short flag + + // 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."; + const VOLUME_STEPS_DESC: &str = + "Number of incremental steps when responding to volume control updates. Defaults to 64."; 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. May be different from the 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. Defaults to 'Speaker'.", "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( + ENABLE_VOLUME_NORMALISATION_SHORT, + ENABLE_VOLUME_NORMALISATION, + "Play all tracks at approximately the same apparent volume.", + ) + .optflag( + ENABLE_OAUTH_SHORT, + ENABLE_OAUTH, + "Perform interactive OAuth sign in.", + ) .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 used to sign in with.", "USERNAME") - .optopt("p", PASSWORD, "Password used to sign in with.", "PASSWORD") - .optopt("", PROXY, "HTTP proxy to use when connecting.", "URL") - .optopt("", AP_PORT, "Connect to an AP with a specified port. If no AP with that port is present a fallback AP will be used. Available ports are usually 80, 443 and 4070.", "PORT") - .optflag("", DISABLE_DISCOVERY, "Disable zeroconf 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. Defaults to the backend's default.", - "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("m", MIXER_TYPE, "Mixer to use {alsa|softvol}. Defaults to softvol", "MIXER") .optopt( + DEVICE_TYPE_SHORT, + DEVICE_TYPE, + "Displayed device type. Defaults to speaker.", + "TYPE", + ).optflag( "", - "mixer-name", // deprecated - "", - "", + DEVICE_IS_GROUP, + "Whether the device represents a group. Defaults to false.", ) .optopt( - "", - ALSA_MIXER_CONTROL, - "Alsa mixer control, e.g. 'PCM', 'Master' or similar. Defaults to 'PCM'.", + 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", // deprecated - "", - "", + USERNAME_SHORT, + USERNAME, + "Username used to sign in with.", + "USERNAME", ) .optopt( - "", + PASSWORD_SHORT, + PASSWORD, + "Password used to sign in with.", + "PASSWORD", + ) + .optopt( + ACCESS_TOKEN_SHORT, + ACCESS_TOKEN, + "Spotify access token to sign in with.", + "TOKEN", + ) + .optopt( + OAUTH_PORT_SHORT, + OAUTH_PORT, + "The port the oauth redirect server uses 1 - 65535. Ports <= 1024 may require root privileges.", + "PORT", + ) + .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, e.g 'hw:0' or similar from `aplay -l`. Defaults to `--device` if specified, 'default' otherwise.", + ALSA_MIXER_DEVICE_DESC, "DEVICE", ) .optopt( - "", - "mixer-index", // deprecated - "", - "", - ) - .optopt( - "", + ALSA_MIXER_INDEX_SHORT, ALSA_MIXER_INDEX, - "Alsa index of the cards mixer. Defaults to 0.", + 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 advertises over zeroconf.", - "PORT", - ) - .optflag( - "", - ENABLE_VOLUME_NORMALISATION, - "Play all tracks at approximately the same apparent volume.", - ) - .optopt( - "", - NORMALISATION_METHOD, - "Specify the normalisation method to use {basic|dynamic}. Defaults to dynamic.", - "METHOD", - ) - .optopt( - "", - NORMALISATION_GAIN_TYPE, - "Specify the normalisation gain type to use {track|album|auto}. Defaults to auto.", - "TYPE", - ) - .optopt( - "", - NORMALISATION_PREGAIN, - "Pregain (dB) applied by volume normalisation. Defaults to 0.", - "PREGAIN", - ) - .optopt( - "", - NORMALISATION_THRESHOLD, - "Threshold (dBFS) at which the dynamic limiter engages to prevent clipping. Defaults to -2.0.", - "THRESHOLD", - ) - .optopt( - "", - NORMALISATION_ATTACK, - "Attack time (ms) in which the dynamic limiter reduces gain. Defaults to 5.", - "TIME", - ) - .optopt( - "", - NORMALISATION_RELEASE, - "Release or decay time (ms) in which the dynamic limiter restores gain. Defaults to 100.", - "TIME", - ) - .optopt( - "", - NORMALISATION_KNEE, - "Knee steepness of the dynamic limiter. Defaults to 1.0.", - "KNEE", - ) - .optopt( - "", + VOLUME_CTRL_SHORT, VOLUME_CTRL, "Volume control scale type {cubic|fixed|linear|log}. Defaults to log.", "VOLUME_CTRL" ) .optopt( - "", + VOLUME_RANGE_SHORT, VOLUME_RANGE, - "Range of the volume control (dB). Default for softvol: 60. For the Alsa mixer: what the control supports.", + VOLUME_RANGE_DESC, "RANGE", ) - .optflag( - "", + .optopt( + VOLUME_STEPS_SHORT, + VOLUME_STEPS, + VOLUME_STEPS_DESC, + "STEPS", + ) + .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|auto}. Defaults to auto.", + "TYPE", + ) + .optopt( + NORMALISATION_PREGAIN_SHORT, + NORMALISATION_PREGAIN, + "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) 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 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 restores gain from 1 to 1000. Defaults to 100.", + "TIME", + ) + .optopt( + NORMALISATION_KNEE_SHORT, + NORMALISATION_KNEE, + "Knee width (dB) of the dynamic limiter from 0.0 to 10.0. Defaults to 5.0.", + "KNEE", + ) + .optopt( + ZEROCONF_PORT_SHORT, + ZEROCONF_PORT, + "The port the internal server advertises over zeroconf 1 - 65535. Ports <= 1024 may require root privileges.", + "PORT", + ) + .optopt( + PROXY_SHORT, + PROXY, + "HTTP proxy to use when connecting.", + "URL", + ) + .optopt( + AP_PORT_SHORT, + AP_PORT, + "Connect to an AP with a specified port 1 - 65535. Available ports are usually 80, 443 and 4070.", + "PORT", + ) + .optopt( + AUTOPLAY_SHORT, AUTOPLAY, - "Automatically play similar songs when your music ends.", + "Explicitly set autoplay {on|off}. Defaults to following the client setting.", + "OVERRIDE", ) - .optflag( - "", - DISABLE_GAPLESS, - "Disable gapless playback.", + .optopt( + ZEROCONF_INTERFACE_SHORT, + ZEROCONF_INTERFACE, + "Comma-separated interface IP addresses on which zeroconf will bind. Defaults to all interfaces. Ignored by DNS-SD.", + "IP" ) - .optflag( - "", + .optopt( + ZEROCONF_BACKEND_SHORT, + ZEROCONF_BACKEND, + "Zeroconf (MDNS/DNS-SD) backend to use. Valid values are 'avahi', 'dns-sd' and 'libmdns', if librespot is compiled with the corresponding feature flags.", + "BACKEND" + ); + + #[cfg(feature = "passthrough-decoder")] + opts.optflag( + PASSTHROUGH_SHORT, PASSTHROUGH, "Pass a raw stream to the output. Only works with the pipe and subprocess backends.", ); + let args: Vec<_> = std::env::args_os() + .filter_map(|s| match s.into_string() { + Ok(valid) => Some(valid), + 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) { + 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!("{}", 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" | "LIBRESPOT_ACCESS_TOKEN" + ) { + trace!("\t\t{k}=\"XXXXXXXX\""); + } 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 = { + let key = key.trim_start_matches('-'); + + if let Some((s, _)) = key.split_once('=') { + s + } else { + key + } + }; + + if index > 0 + && key.starts_with('-') + && &args[index - 1] != key + && matches.opt_defined(opt) + && matches.opt_present(opt) + { + if matches!( + opt, + PASSWORD + | PASSWORD_SHORT + | USERNAME + | USERNAME_SHORT + | ACCESS_TOKEN + | ACCESS_TOKEN_SHORT + ) { + // Don't log creds. + trace!("\t\t{opt} \"XXXXXXXX\""); + } else { + let value = matches.opt_str(opt).unwrap_or_default(); + if value.is_empty() { + trace!("\t\t{opt}"); + } else { + trace!("\t\t{opt} \"{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 format = matches - .opt_str(FORMAT) - .as_deref() - .map(|format| AudioFormat::from_str(format).expect("Invalid output format")) - .unwrap_or_default(); - - let device = matches.opt_str(DEVICE); - if device == Some("?".into()) { - backend(device, format); - exit(0); + // Can't use `-> fmt::Arguments` due to https://github.com/rust-lang/rust/issues/92698 + fn format_flag(long: &str, short: &str) -> String { + if short.is_empty() { + format!("`--{long}`") + } else { + format!("`--{long}` / `-{short}`") + } } - let mixer_type = matches.opt_str(MIXER_TYPE); - let mixer = mixer::find(mixer_type.as_deref()).expect("Invalid mixer"); + let invalid_error_msg = + |long: &str, short: &str, invalid: &str, valid_values: &str, default_value: &str| { + let flag = format_flag(long, short); + error!("Invalid {flag}: \"{invalid}\""); + + if !valid_values.is_empty() { + println!("Valid {flag} values: {valid_values}"); + } + + if !default_value.is_empty() { + println!("Default: {default_value}"); + } + }; + + let empty_string_error_msg = |long: &str, short: &str| { + error!("`--{long}` / `-{short}` can not be an empty string"); + 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).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 = opt_str(DEVICE); + 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); + } + } + + #[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 is_alsa_mixer = match mixer_type.as_deref() { + #[cfg(feature = "alsa-backend")] + Some(AlsaMixer::NAME) => true, + _ => false, + }; + + #[cfg(feature = "alsa-backend")] + if !is_alsa_mixer { + for a in &[ALSA_MIXER_DEVICE, ALSA_MIXER_INDEX, ALSA_MIXER_CONTROL] { + if opt_present(a) { + warn!("Alsa specific mixer options have no effect if not using the alsa mixer."); + break; + } + } + } let mixer_config = { - let mixer_device = match matches.opt_str("mixer-card") { - Some(card) => { - warn!("--mixer-card is deprecated and will be removed in a future release."); - warn!("Please use --alsa-mixer-device instead."); - card - } - None => matches.opt_str(ALSA_MIXER_DEVICE).unwrap_or_else(|| { - if let Some(ref device_name) = device { - device_name.to_string() - } else { - MixerConfig::default().device - } - }), - }; + let mixer_default_config = MixerConfig::default(); - let index = match matches.opt_str("mixer-index") { - Some(index) => { - warn!("--mixer-index is deprecated and will be removed in a future release."); - warn!("Please use --alsa-mixer-index instead."); - index - .parse::() - .expect("Mixer index is not a valid number") - } - None => matches - .opt_str(ALSA_MIXER_INDEX) + #[cfg(feature = "alsa-backend")] + let index = if !is_alsa_mixer { + mixer_default_config.index + } else { + opt_str(ALSA_MIXER_INDEX) .map(|index| { - index - .parse::() - .expect("Alsa mixer index is not a valid number") + 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(|| match device { + // Look for the dev index portion of --device. + // Specifically when --device is :CARD=,DEV= + // or :,. + + // If --device does not contain a ',' it does not contain a dev index. + // In the case that the dev index is omitted it is assumed to be 0 (mixer_default_config.index). + // Malformed --device values will also fallback to mixer_default_config.index. + Some(ref device_name) if device_name.contains(',') => { + // Turn :CARD=,DEV= or :, + // into DEV= or . + let dev = &device_name[device_name.find(',').unwrap_or_default()..] + .trim_start_matches(','); + + // Turn DEV= into (noop if it's already ) + // and then parse . + // Malformed --device values will fail the parse and fallback to mixer_default_config.index. + dev[dev.find('=').unwrap_or_default()..] + .trim_start_matches('=') + .parse::() + .unwrap_or(mixer_default_config.index) + } + _ => mixer_default_config.index, }) - .unwrap_or(0), }; - let control = match matches.opt_str("mixer-name") { - Some(name) => { - warn!("--mixer-name is deprecated and will be removed in a future release."); - warn!("Please use --alsa-mixer-control instead."); - name + #[cfg(not(feature = "alsa-backend"))] + let index = mixer_default_config.index; + + #[cfg(feature = "alsa-backend")] + let device = if !is_alsa_mixer { + mixer_default_config.device + } else { + match opt_str(ALSA_MIXER_DEVICE) { + Some(mixer_device) => { + if mixer_device.is_empty() { + empty_string_error_msg(ALSA_MIXER_DEVICE, ALSA_MIXER_DEVICE_SHORT); + } + + mixer_device + } + None => match device { + Some(ref device_name) => { + // Look for the card name or card index portion of --device. + // Specifically when --device is :CARD=,DEV= + // or card index when --device is :,. + // --device values like `pulse`, `default`, `jack` may be valid but there is no way to + // infer automatically what the mixer should be so they fail auto fallback + // so --alsa-mixer-device must be manually specified in those situations. + let start_index = device_name.find(':').unwrap_or_default(); + + let end_index = match device_name.find(',') { + Some(index) if index > start_index => index, + _ => device_name.len(), + }; + + let card = &device_name[start_index..end_index]; + + if card.starts_with(':') { + // mixers are assumed to be hw:CARD= or hw:. + "hw".to_owned() + card + } else { + error!( + "Could not find an alsa mixer for \"{}\", it must be specified with `--{}` / `-{}`", + &device.unwrap_or_default(), + ALSA_MIXER_DEVICE, + ALSA_MIXER_DEVICE_SHORT + ); + + exit(1); + } + } + None => { + error!( + "`--{}` / `-{}` or `--{}` / `-{}` \ + must be specified when `--{}` / `-{}` is set to \"alsa\"", + DEVICE, + DEVICE_SHORT, + ALSA_MIXER_DEVICE, + ALSA_MIXER_DEVICE_SHORT, + MIXER_TYPE, + MIXER_TYPE_SHORT + ); + + exit(1); + } + }, } - None => matches - .opt_str(ALSA_MIXER_CONTROL) - .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_type.as_deref() { - #[cfg(feature = "alsa-backend")] - 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 - ); + #[cfg(not(feature = "alsa-backend"))] + let device = mixer_default_config.device; + + #[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); } - let volume_ctrl = matches - .opt_str(VOLUME_CTRL) - .as_deref() - .map(|volume_ctrl| { - VolumeCtrl::from_str_with_range(volume_ctrl, volume_range) - .expect("Invalid volume control type") + + #[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(|| { - let mut volume_ctrl = VolumeCtrl::default(); - volume_ctrl.set_db_range(volume_range); - volume_ctrl + if is_alsa_mixer { + 0.0 + } else { + VolumeCtrl::DEFAULT_DB_RANGE + } }); + let volume_ctrl = opt_str(VOLUME_CTRL) + .as_deref() + .map(|volume_ctrl| { + 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(|| VolumeCtrl::Log(volume_range)); + MixerConfig { - device: mixer_device, + 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 enable_oauth = opt_present(ENABLE_OAUTH); + + 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); }) }) @@ -593,162 +1168,642 @@ 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 `--{CACHE}` / `-{CACHE_SHORT}` path, and/or if the `--{DISABLE_AUDIO_CACHE}` / `-{DISABLE_AUDIO_CACHE_SHORT}` flag is set, `--{CACHE_SIZE_LIMIT}` / `-{CACHE_SIZE_LIMIT_SHORT}` has no effect." + ); + } + + let cache = match Cache::new(cred_dir.clone(), volume_dir, audio_dir, limit) { Ok(cache) => Some(cache), Err(e) => { - warn!("Cannot create cache: {}", e); + warn!("Cannot create cache: {e}"); None } - } - }; - - 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_type.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 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, - ) + if enable_oauth && (cache.is_none() || cred_dir.is_none()) { + warn!("Credential caching is unavailable, but advisable when using OAuth login."); + } + + cache }; - let session_config = { - let device_id = device_id(&name); + let credentials = { + let cached_creds = cache.as_ref().and_then(Cache::credentials); - 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 url.scheme() != "http" { - panic!("Only unsecure http:// proxies are supported"); - } - 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")), + if let Some(access_token) = opt_str(ACCESS_TOKEN) { + if access_token.is_empty() { + empty_string_error_msg(ACCESS_TOKEN, ACCESS_TOKEN_SHORT); + } + Some(Credentials::with_access_token(access_token)) + } else if let Some(username) = opt_str(USERNAME) { + if username.is_empty() { + empty_string_error_msg(USERNAME, USERNAME_SHORT); + } + if opt_present(PASSWORD) { + error!( + "Invalid `--{PASSWORD}` / `-{PASSWORD_SHORT}`: Password authentication no longer supported, use OAuth" + ); + exit(1); + } + match cached_creds { + Some(creds) if Some(username) == creds.username => { + trace!("Using cached credentials for specified username."); + Some(creds) + } + _ => { + trace!("No cached credentials for specified username."); + None + } + } + } else { + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds } }; + let no_discovery_reason = if !cfg!(any( + feature = "with-libmdns", + feature = "with-dns-sd", + feature = "with-avahi" + )) { + Some("librespot compiled without zeroconf backend".to_owned()) + } else if opt_present(DISABLE_DISCOVERY) { + Some(format!( + "the `--{DISABLE_DISCOVERY}` / `-{DISABLE_DISCOVERY_SHORT}` flag set", + )) + } else { + None + }; + + if credentials.is_none() && no_discovery_reason.is_some() && !enable_oauth { + error!("Credentials are required if discovery and oauth login are disabled."); + exit(1); + } + + let oauth_port = if opt_present(OAUTH_PORT) { + if !enable_oauth { + warn!( + "Without the `--{ENABLE_OAUTH}` / `-{ENABLE_OAUTH_SHORT}` flag set `--{OAUTH_PORT}` / `-{OAUTH_PORT_SHORT}` has no effect." + ); + } + opt_str(OAUTH_PORT) + .map(|port| match port.parse::() { + Ok(value) => { + if value > 0 { + Some(value) + } else { + None + } + } + _ => { + let valid_values = &format!("1 - {}", u16::MAX); + invalid_error_msg(OAUTH_PORT, OAUTH_PORT_SHORT, &port, valid_values, ""); + + exit(1); + } + }) + .unwrap_or(None) + } else { + Some(5588) + }; + + if let Some(reason) = no_discovery_reason.as_deref() { + if opt_present(ZEROCONF_PORT) { + warn!("With {reason} `--{ZEROCONF_PORT}` / `-{ZEROCONF_PORT_SHORT}` has no effect."); + } + } + + let zeroconf_port = if no_discovery_reason.is_none() { + 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); + } + }) + .unwrap_or(0) + } else { + 0 + }; + + // #1046: not all connections are supplied an `autoplay` user attribute to run statelessly. + // This knob allows for a manual override. + let autoplay = match opt_str(AUTOPLAY) { + Some(value) => match value.as_ref() { + "on" => Some(true), + "off" => Some(false), + _ => { + invalid_error_msg( + AUTOPLAY, + AUTOPLAY_SHORT, + &opt_str(AUTOPLAY).unwrap_or_default(), + "on, off", + "", + ); + exit(1); + } + }, + None => SessionConfig::default().autoplay, + }; + + if let Some(reason) = no_discovery_reason.as_deref() { + if opt_present(ZEROCONF_INTERFACE) { + warn!( + "With {} {} has no effect.", + reason, + format_flag(ZEROCONF_INTERFACE, ZEROCONF_INTERFACE_SHORT), + ); + } + } + + let zeroconf_ip: Vec = if opt_present(ZEROCONF_INTERFACE) { + if let Some(zeroconf_ip) = opt_str(ZEROCONF_INTERFACE) { + zeroconf_ip + .split(',') + .map(|s| { + s.trim().parse::().unwrap_or_else(|_| { + invalid_error_msg( + ZEROCONF_INTERFACE, + ZEROCONF_INTERFACE_SHORT, + s, + "IPv4 and IPv6 addresses", + "", + ); + exit(1); + }) + }) + .collect() + } else { + warn!("Unable to use zeroconf-interface option, default to all interfaces."); + vec![] + } + } else { + vec![] + }; + + if let Some(reason) = no_discovery_reason.as_deref() { + if opt_present(ZEROCONF_BACKEND) { + warn!( + "With {reason} `--{ZEROCONF_BACKEND}` / `-{ZEROCONF_BACKEND_SHORT}` has no effect." + ); + } + } + + let zeroconf_backend_name = opt_str(ZEROCONF_BACKEND); + let zeroconf_backend = no_discovery_reason.is_none().then(|| { + librespot::discovery::find(zeroconf_backend_name.as_deref()).unwrap_or_else(|_| { + let available_backends: Vec<_> = librespot::discovery::BACKENDS + .iter() + .filter_map(|(id, launch_svc)| launch_svc.map(|_| *id)) + .collect(); + let default_backend = librespot::discovery::BACKENDS + .iter() + .find_map(|(id, launch_svc)| launch_svc.map(|_| *id)) + .unwrap_or(""); + + invalid_error_msg( + ZEROCONF_BACKEND, + ZEROCONF_BACKEND_SHORT, + &zeroconf_backend_name.unwrap_or_default(), + &available_backends.join(", "), + default_backend, + ); + + exit(1); + }) + }); + + let connect_config = { + let connect_default_config = ConnectConfig::default(); + + let name = opt_str(NAME); + if matches!(name, Some(ref name) if name.is_empty()) { + empty_string_error_msg(NAME, NAME_SHORT); + exit(1); + } + + #[cfg(feature = "pulseaudio-backend")] + { + if env::var("PULSE_PROP_application.name").is_err() { + let op_pulseaudio_name = name + .as_ref() + .map(|name| format!("{} - {}", connect_default_config.name, name)); + + let pulseaudio_name = op_pulseaudio_name + .as_deref() + .unwrap_or(&connect_default_config.name); + + set_env_var("PULSE_PROP_application.name", pulseaudio_name).await; + } + + if env::var("PULSE_PROP_application.version").is_err() { + set_env_var("PULSE_PROP_application.version", version::SEMVER).await; + } + + if env::var("PULSE_PROP_application.icon_name").is_err() { + set_env_var("PULSE_PROP_application.icon_name", "audio-x-generic").await; + } + + if env::var("PULSE_PROP_application.process.binary").is_err() { + set_env_var("PULSE_PROP_application.process.binary", "librespot").await; + } + + if env::var("PULSE_PROP_stream.description").is_err() { + set_env_var("PULSE_PROP_stream.description", "Spotify Connect endpoint").await; + } + + if env::var("PULSE_PROP_media.software").is_err() { + set_env_var("PULSE_PROP_media.software", "Spotify").await; + } + + if env::var("PULSE_PROP_media.role").is_err() { + set_env_var("PULSE_PROP_media.role", "music").await; + } + } + + 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 + ); + + #[cfg(not(feature = "alsa-backend"))] + let default_value = &connect_default_config.initial_volume.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(|| { + if is_alsa_mixer { + None + } else { + 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", + DeviceType::default().into(), + ); + + exit(1); + }) + }); + + let volume_steps = opt_str(VOLUME_STEPS).map(|steps| match steps.parse::() { + Ok(value) => value, + _ => { + let default_value = &connect_default_config.volume_steps.to_string(); + + invalid_error_msg( + VOLUME_STEPS, + VOLUME_STEPS_SHORT, + &steps, + "a positive whole number <= 65535", + default_value, + ); + + exit(1); + } + }); + + let is_group = opt_present(DEVICE_IS_GROUP); + + // use config defaults if not provided + let name = name.unwrap_or(connect_default_config.name); + let device_type = device_type.unwrap_or(connect_default_config.device_type); + let initial_volume = initial_volume.unwrap_or(connect_default_config.initial_volume); + let volume_steps = volume_steps.unwrap_or(connect_default_config.volume_steps); + + ConnectConfig { + name, + device_type, + is_group, + initial_volume, + volume_steps, + ..connect_default_config + } + }; + + 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: \"{e}\", only URLs in the format \"http(s)://host:port\" are allowed"); + 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); + } + }), + tmp_dir, + autoplay, + ..SessionConfig::default() + }; + let player_config = { - let bitrate = matches - .opt_str(BITRATE) - .as_deref() - .map(|bitrate| Bitrate::from_str(bitrate).expect("Invalid bitrate")) - .unwrap_or_default(); + let player_default_config = PlayerConfig::default(); - let gapless = !matches.opt_present(DISABLE_GAPLESS); - - let normalisation = matches.opt_present(ENABLE_VOLUME_NORMALISATION); - let normalisation_method = matches - .opt_str(NORMALISATION_METHOD) + let bitrate = opt_str(BITRATE) .as_deref() - .map(|method| { - NormalisationMethod::from_str(method).expect("Invalid normalisation method") + .map(|bitrate| { + Bitrate::from_str(bitrate).unwrap_or_else(|_| { + invalid_error_msg(BITRATE, BITRATE_SHORT, bitrate, "96, 160, 320", "160"); + exit(1); + }) }) - .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); + .unwrap_or(player_default_config.bitrate); - 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 gapless = !opt_present(DISABLE_GAPLESS); + + let normalisation = opt_present(ENABLE_VOLUME_NORMALISATION); + + let normalisation_method; + let normalisation_type; + let normalisation_pregain_db; + let normalisation_threshold_dbfs; + let normalisation_attack_cf; + let normalisation_release_cf; + let normalisation_knee_db; + + 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 `--{ENABLE_VOLUME_NORMALISATION}` / `-{ENABLE_VOLUME_NORMALISATION_SHORT}` flag normalisation options have no effect.", + ); + break; } - Some(dither::find_ditherer(ditherer_name).expect("Invalid ditherer")) } - // nothing set on command line => use default + + normalisation_method = player_default_config.normalisation_method; + normalisation_type = player_default_config.normalisation_type; + normalisation_pregain_db = player_default_config.normalisation_pregain_db; + normalisation_threshold_dbfs = player_default_config.normalisation_threshold_dbfs; + normalisation_attack_cf = player_default_config.normalisation_attack_cf; + normalisation_release_cf = player_default_config.normalisation_release_cf; + normalisation_knee_db = player_default_config.normalisation_knee_db; + } else { + normalisation_method = opt_str(NORMALISATION_METHOD) + .as_deref() + .map(|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); + }) + }) + .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_db = 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_db.to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_pregain_db); + + normalisation_threshold_dbfs = opt_str(NORMALISATION_THRESHOLD) + .map(|threshold| match threshold.parse::() { + Ok(value) if (VALID_NORMALISATION_THRESHOLD_RANGE).contains(&value) => 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, + &player_default_config + .normalisation_threshold_dbfs + .to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_threshold_dbfs); + + normalisation_attack_cf = opt_str(NORMALISATION_ATTACK) + .map(|attack| match attack.parse::() { + Ok(value) if (VALID_NORMALISATION_ATTACK_RANGE).contains(&value) => { + duration_to_coefficient(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, + &coefficient_to_duration(player_default_config.normalisation_attack_cf) + .as_millis() + .to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_attack_cf); + + normalisation_release_cf = opt_str(NORMALISATION_RELEASE) + .map(|release| match release.parse::() { + Ok(value) if (VALID_NORMALISATION_RELEASE_RANGE).contains(&value) => { + duration_to_coefficient(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, + &coefficient_to_duration( + player_default_config.normalisation_release_cf, + ) + .as_millis() + .to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_release_cf); + + normalisation_knee_db = 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_db.to_string(), + ); + + exit(1); + } + }) + .unwrap_or(player_default_config.normalisation_knee_db); + } + + 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); + #[cfg(feature = "passthrough-decoder")] + let passthrough = opt_present(PASSTHROUGH); + #[cfg(not(feature = "passthrough-decoder"))] + let passthrough = false; PlayerConfig { bitrate, @@ -757,36 +1812,18 @@ fn get_setup(args: &[String]) -> Setup { normalisation, normalisation_type, normalisation_method, - normalisation_pregain, - normalisation_threshold, - normalisation_attack, - normalisation_release, - normalisation_knee, + normalisation_pregain_db, + normalisation_threshold_dbfs, + normalisation_attack_cf, + normalisation_release_cf, + normalisation_knee_db, ditherer, + position_update_interval: None, } }; - 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, @@ -799,204 +1836,276 @@ fn get_setup(args: &[String]) -> Setup { connect_config, mixer_config, credentials, - enable_discovery, + enable_oauth, + oauth_port, zeroconf_port, player_event_program, emit_sink_events, + zeroconf_ip, + zeroconf_backend, } } +// Initialize a static semaphore with only one permit, which is used to +// prevent setting environment variables from running in parallel. +static PERMIT: Semaphore = Semaphore::const_new(1); +async fn set_env_var, V: AsRef>(key: K, value: V) { + let permit = PERMIT + .acquire() + .await + .expect("Failed to acquire semaphore permit"); + + // SAFETY: This is safe because setting the environment variable will wait if the permit is + // already acquired by other callers. + unsafe { env::set_var(key, value) } + + // Drop the permit manually, so the compiler doesn't optimize it away as unused variable. + drop(permit); +} + #[tokio::main(flavor = "current_thread")] async fn main() { const RUST_BACKTRACE: &str = "RUST_BACKTRACE"; + const RECONNECT_RATE_LIMIT_WINDOW: Duration = Duration::from_secs(600); + const DISCOVERY_RETRY_TIMEOUT: Duration = Duration::from_secs(10); + const RECONNECT_RATE_LIMIT: usize = 5; + if env::var(RUST_BACKTRACE).is_err() { - env::set_var(RUST_BACKTRACE, "full") + set_env_var(RUST_BACKTRACE, "full").await; } - let args: Vec = std::env::args().collect(); - let setup = get_setup(&args); + let setup = get_setup().await; let mut last_credentials = None; let mut spirc: Option = None; let mut spirc_task: Option> = None; - let mut player_event_channel: Option> = None; let mut auto_connect_times: Vec = vec![]; let mut discovery = None; - let mut connecting: Pin>> = Box::pin(future::pending()); + let mut connecting = false; + let mut _event_handler: Option = None; - if setup.enable_discovery { - let device_id = setup.session_config.device_id.clone(); + let mut session = Session::new(setup.session_config.clone(), setup.cache.clone()); - discovery = Some( - librespot::discovery::Discovery::builder(device_id) + let mut sys = System::new(); + + if let Some(zeroconf_backend) = setup.zeroconf_backend { + // When started at boot as a service discovery may fail due to it + // trying to bind to interfaces before the network is actually up. + // This could be prevented in systemd by starting the service after + // network-online.target but it requires that a wait-online.service is + // also enabled which is not always the case since a wait-online.service + // can potentially hang the boot process until it times out in certain situations. + // This allows for discovery to retry every 10 secs in the 1st min of uptime + // before giving up thus papering over the issue and not holding up the boot process. + + discovery = loop { + let device_id = setup.session_config.device_id.clone(); + let client_id = setup.session_config.client_id.clone(); + + match librespot::discovery::Discovery::builder(device_id, client_id) .name(setup.connect_config.name.clone()) .device_type(setup.connect_config.device_type) + .is_group(setup.connect_config.is_group) .port(setup.zeroconf_port) + .zeroconf_ip(setup.zeroconf_ip.clone()) + .zeroconf_backend(zeroconf_backend) .launch() - .unwrap(), - ); + { + Ok(d) => break Some(d), + Err(e) => { + sys.refresh_processes(ProcessesToUpdate::All, true); + + if System::uptime() <= 1 { + debug!("Retrying to initialise discovery: {e}"); + tokio::time::sleep(DISCOVERY_RETRY_TIMEOUT).await; + } else { + debug!("System uptime > 1 min, not retrying to initialise discovery"); + warn!("Could not initialise discovery: {e}"); + break None; + } + } + } + }; } if let Some(credentials) = setup.credentials { - last_credentials = Some(credentials.clone()); - connecting = Box::pin( - Session::connect( - setup.session_config.clone(), - credentials, - setup.cache.clone(), - ) - .fuse(), + last_credentials = Some(credentials); + connecting = true; + } else if setup.enable_oauth { + let port_str = match setup.oauth_port { + Some(port) => format!(":{port}"), + _ => String::new(), + }; + let client = OAuthClientBuilder::new( + &setup.session_config.client_id, + &format!("http://127.0.0.1{port_str}/login"), + OAUTH_SCOPES.to_vec(), + ) + .open_in_browser() + .build() + .unwrap_or_else(|e| { + error!("Failed to create OAuth client: {e}"); + exit(1); + }); + let oauth_token = client.get_access_token().unwrap_or_else(|e| { + error!("Failed to get Spotify access token: {e}"); + exit(1); + }); + last_credentials = Some(Credentials::with_access_token(oauth_token.access_token)); + connecting = true; + } else if discovery.is_none() { + error!( + "Discovery is unavailable and no credentials provided. Authentication is not possible." ); + exit(1); + } + + let mixer_config = setup.mixer_config.clone(); + let mixer = match (setup.mixer)(mixer_config) { + Ok(mixer) => mixer, + Err(why) => { + error!("{why}"); + exit(1) + } + }; + let player_config = setup.player_config.clone(); + + let soft_volume = mixer.get_soft_volume(); + let format = setup.format; + let backend = setup.backend; + let device = setup.device.clone(); + let player = Player::new(player_config, session.clone(), soft_volume, move || { + (backend)(device, format) + }); + + if let Some(player_event_program) = setup.player_event_program.clone() { + _event_handler = Some(EventHandler::new( + player.get_player_event_channel(), + &player_event_program, + )); + + if setup.emit_sink_events { + player.set_sink_event_callback(Some(Box::new(move |sink_status| { + run_program_on_sink_events(sink_status, &player_event_program) + }))); + } } loop { tokio::select! { - credentials = async { discovery.as_mut().unwrap().next().await }, if discovery.is_some() => { + credentials = async { + match discovery.as_mut() { + Some(d) => d.next().await, + _ => None + } + }, if discovery.is_some() => { match credentials { Some(credentials) => { last_credentials = Some(credentials.clone()); auto_connect_times.clear(); if let Some(spirc) = spirc.take() { - spirc.shutdown(); + if let Err(e) = spirc.shutdown() { + error!("error sending spirc shutdown message: {e}"); + } } if let Some(spirc_task) = spirc_task.take() { // Continue shutdown in its own task tokio::spawn(spirc_task); } + if !session.is_invalid() { + session.shutdown(); + } - connecting = Box::pin(Session::connect( - setup.session_config.clone(), - credentials, - setup.cache.clone(), - ).fuse()); + connecting = true; }, None => { - warn!("Discovery stopped!"); - discovery = None; + error!("Discovery stopped unexpectedly"); + exit(1); } } }, - session = &mut connecting, if !connecting.is_terminated() => match session { - Ok(session) => { - let mixer_config = setup.mixer_config.clone(); - let mixer = (setup.mixer)(mixer_config); - let player_config = setup.player_config.clone(); - let connect_config = setup.connect_config.clone(); - - let audio_filter = mixer.get_audio_filter(); - let format = setup.format; - let backend = setup.backend; - let device = setup.device.clone(); - let (player, event_channel) = - Player::new(player_config, session.clone(), audio_filter, move || { - (backend)(device, format) - }); - - if setup.emit_sink_events { - if let Some(player_event_program) = setup.player_event_program.clone() { - player.set_sink_event_callback(Some(Box::new(move |sink_status| { - match emit_sink_event(sink_status, &player_event_program) { - Ok(e) if e.success() => (), - Ok(e) => { - if let Some(code) = e.code() { - warn!("Sink event program returned exit code {}", code); - } else { - warn!("Sink event program returned failure"); - } - }, - Err(e) => { - warn!("Emitting sink event failed: {}", e); - }, - } - }))); - } - }; - - let (spirc_, spirc_task_) = Spirc::new(connect_config, session, player, mixer); - - spirc = Some(spirc_); - spirc_task = Some(Box::pin(spirc_task_)); - player_event_channel = Some(event_channel); - }, - Err(e) => { - warn!("Connection failed: {}", e); + _ = async {}, if connecting && last_credentials.is_some() => { + if session.is_invalid() { + session = Session::new(setup.session_config.clone(), setup.cache.clone()); + player.set_session(session.clone()); } + + let connect_config = setup.connect_config.clone(); + + let (spirc_, spirc_task_) = match Spirc::new(connect_config, + session.clone(), + last_credentials.clone().unwrap_or_default(), + player.clone(), + mixer.clone()).await { + Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_), + Err(e) => { + error!("could not initialize spirc: {e}"); + exit(1); + } + }; + spirc = Some(spirc_); + spirc_task = Some(Box::pin(spirc_task_)); + + connecting = false; }, - _ = async { spirc_task.as_mut().unwrap().await }, if spirc_task.is_some() => { + _ = async { + if let Some(task) = spirc_task.as_mut() { + task.await; + } + }, if spirc_task.is_some() && !connecting => { spirc_task = None; warn!("Spirc shut down unexpectedly"); - while !auto_connect_times.is_empty() - && ((Instant::now() - auto_connect_times[0]).as_secs() > 600) - { - let _ = auto_connect_times.remove(0); - } - if let Some(credentials) = last_credentials.clone() { - if auto_connect_times.len() >= 5 { - warn!("Spirc shut down too often. Not reconnecting automatically."); - } else { - auto_connect_times.push(Instant::now()); + let mut reconnect_exceeds_rate_limit = || { + auto_connect_times.retain(|&t| t.elapsed() < RECONNECT_RATE_LIMIT_WINDOW); + auto_connect_times.len() > RECONNECT_RATE_LIMIT + }; - connecting = Box::pin(Session::connect( - setup.session_config.clone(), - credentials, - setup.cache.clone(), - ).fuse()); + if last_credentials.is_some() && !reconnect_exceeds_rate_limit() { + auto_connect_times.push(Instant::now()); + if !session.is_invalid() { + session.shutdown(); } + connecting = true; + } else { + error!("Spirc shut down too often. Not reconnecting automatically."); + exit(1); } }, - event = async { player_event_channel.as_mut().unwrap().recv().await }, if player_event_channel.is_some() => match event { - Some(event) => { - if let Some(program) = &setup.player_event_program { - if let Some(child) = run_program_on_events(event, program) { - if child.is_ok() { - - let mut child = child.unwrap(); - - tokio::spawn(async move { - match child.wait().await { - Ok(e) if e.success() => (), - Ok(e) => { - if let Some(code) = e.code() { - warn!("On event program returned exit code {}", code); - } else { - warn!("On event program returned failure"); - } - }, - Err(e) => { - warn!("On event program failed: {}", e); - }, - } - }); - } else { - warn!("On event program failed to start"); - } - } - } - }, - None => { - player_event_channel = None; - } + _ = async {}, if player.is_invalid() => { + error!("Player shut down unexpectedly"); + exit(1); }, _ = tokio::signal::ctrl_c() => { break; - } + }, + else => break, } } info!("Gracefully shutting down"); + let mut shutdown_tasks = tokio::task::JoinSet::new(); + // 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! { - _ = tokio::signal::ctrl_c() => (), - _ = spirc_task.as_mut() => () - } + if let Some(spirc_task) = spirc_task { + shutdown_tasks.spawn(spirc_task); } } + + if let Some(discovery) = discovery { + shutdown_tasks.spawn(discovery.shutdown()); + } + + tokio::select! { + _ = tokio::signal::ctrl_c() => (), + _ = shutdown_tasks.join_all() => (), + } } diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 4c75128c..51495932 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -1,89 +1,328 @@ -use librespot::playback::player::PlayerEvent; -use librespot::playback::player::SinkStatus; -use log::info; -use tokio::process::{Child as AsyncChild, Command as AsyncCommand}; +use log::{debug, error, warn}; -use std::collections::HashMap; -use std::io; -use std::process::{Command, ExitStatus}; +use std::{collections::HashMap, process::Command, thread}; -pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option> { - let mut env_vars = HashMap::new(); - match event { - PlayerEvent::Changed { - old_track_id, - new_track_id, - } => { - env_vars.insert("PLAYER_EVENT", "changed".to_string()); - env_vars.insert("OLD_TRACK_ID", old_track_id.to_base62()); - env_vars.insert("TRACK_ID", new_track_id.to_base62()); - } - PlayerEvent::Started { track_id, .. } => { - env_vars.insert("PLAYER_EVENT", "started".to_string()); - env_vars.insert("TRACK_ID", track_id.to_base62()); - } - PlayerEvent::Stopped { track_id, .. } => { - env_vars.insert("PLAYER_EVENT", "stopped".to_string()); - env_vars.insert("TRACK_ID", track_id.to_base62()); - } - PlayerEvent::Playing { - track_id, - duration_ms, - position_ms, - .. - } => { - env_vars.insert("PLAYER_EVENT", "playing".to_string()); - env_vars.insert("TRACK_ID", track_id.to_base62()); - env_vars.insert("DURATION_MS", duration_ms.to_string()); - env_vars.insert("POSITION_MS", position_ms.to_string()); - } - PlayerEvent::Paused { - track_id, - duration_ms, - position_ms, - .. - } => { - env_vars.insert("PLAYER_EVENT", "paused".to_string()); - env_vars.insert("TRACK_ID", track_id.to_base62()); - env_vars.insert("DURATION_MS", duration_ms.to_string()); - env_vars.insert("POSITION_MS", position_ms.to_string()); - } - PlayerEvent::Preloading { track_id, .. } => { - env_vars.insert("PLAYER_EVENT", "preloading".to_string()); - env_vars.insert("TRACK_ID", track_id.to_base62()); - } - PlayerEvent::VolumeSet { volume } => { - env_vars.insert("PLAYER_EVENT", "volume_set".to_string()); - env_vars.insert("VOLUME", volume.to_string()); - } - _ => return None, - } +use librespot::{ + metadata::audio::UniqueFields, + playback::player::{PlayerEvent, PlayerEventChannel, SinkStatus}, +}; - let mut v: Vec<&str> = onevent.split_whitespace().collect(); - info!("Running {:?} with environment variables {:?}", v, env_vars); - Some( - AsyncCommand::new(&v.remove(0)) - .args(&v) - .envs(env_vars.iter()) - .spawn(), - ) +pub struct EventHandler { + thread_handle: Option>, } -pub fn emit_sink_event(sink_status: SinkStatus, onevent: &str) -> io::Result { +impl EventHandler { + pub fn new(mut player_events: PlayerEventChannel, onevent: &str) -> Self { + let on_event = onevent.to_string(); + let thread_handle = Some(thread::spawn(move || { + loop { + match player_events.blocking_recv() { + None => break, + Some(event) => { + let mut env_vars = HashMap::new(); + + match event { + PlayerEvent::PlayRequestIdChanged { play_request_id } => { + env_vars + .insert("PLAYER_EVENT", "play_request_id_changed".to_string()); + env_vars.insert("PLAY_REQUEST_ID", play_request_id.to_string()); + } + PlayerEvent::TrackChanged { audio_item } => { + match audio_item.track_id.to_id() { + Err(e) => { + warn!("PlayerEvent::TrackChanged: Invalid track id: {e}") + } + Ok(id) => { + env_vars + .insert("PLAYER_EVENT", "track_changed".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("URI", audio_item.uri); + env_vars.insert("NAME", audio_item.name); + env_vars.insert( + "COVERS", + audio_item + .covers + .into_iter() + .map(|c| c.url) + .collect::>() + .join("\n"), + ); + env_vars.insert("LANGUAGE", audio_item.language.join("\n")); + env_vars.insert( + "DURATION_MS", + audio_item.duration_ms.to_string(), + ); + env_vars.insert( + "IS_EXPLICIT", + audio_item.is_explicit.to_string(), + ); + + match audio_item.unique_fields { + UniqueFields::Track { + artists, + album, + album_artists, + popularity, + number, + disc_number, + } => { + env_vars.insert("ITEM_TYPE", "Track".to_string()); + env_vars.insert( + "ARTISTS", + artists + .0 + .into_iter() + .map(|a| a.name) + .collect::>() + .join("\n"), + ); + env_vars.insert( + "ALBUM_ARTISTS", + album_artists.join("\n"), + ); + env_vars.insert("ALBUM", album); + env_vars + .insert("POPULARITY", popularity.to_string()); + env_vars.insert("NUMBER", number.to_string()); + env_vars + .insert("DISC_NUMBER", disc_number.to_string()); + } + UniqueFields::Episode { + description, + publish_time, + show_name, + } => { + env_vars.insert("ITEM_TYPE", "Episode".to_string()); + env_vars.insert("DESCRIPTION", description); + env_vars.insert( + "PUBLISH_TIME", + publish_time.unix_timestamp().to_string(), + ); + env_vars.insert("SHOW_NAME", show_name); + } + } + } + } + } + PlayerEvent::Stopped { track_id, .. } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Stopped: Invalid track id: {e}"), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "stopped".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::Playing { + track_id, + position_ms, + .. + } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Playing: Invalid track id: {e}"), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "playing".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("POSITION_MS", position_ms.to_string()); + } + }, + PlayerEvent::Paused { + track_id, + position_ms, + .. + } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Paused: Invalid track id: {e}"), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "paused".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("POSITION_MS", position_ms.to_string()); + } + }, + PlayerEvent::Loading { track_id, .. } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Loading: Invalid track id: {e}"), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "loading".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::Preloading { track_id, .. } => match track_id.to_id() { + Err(e) => { + warn!("PlayerEvent::Preloading: Invalid track id: {e}") + } + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "preloading".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::TimeToPreloadNextTrack { track_id, .. } => { + match track_id.to_id() { + Err(e) => warn!( + "PlayerEvent::TimeToPreloadNextTrack: Invalid track id: {e}" + ), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "preload_next".to_string()); + env_vars.insert("TRACK_ID", id); + } + } + } + PlayerEvent::EndOfTrack { track_id, .. } => match track_id.to_id() { + Err(e) => { + warn!("PlayerEvent::EndOfTrack: Invalid track id: {e}") + } + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "end_of_track".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::Unavailable { track_id, .. } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Unavailable: Invalid track id: {e}"), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "unavailable".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::VolumeChanged { volume } => { + env_vars.insert("PLAYER_EVENT", "volume_changed".to_string()); + env_vars.insert("VOLUME", volume.to_string()); + } + PlayerEvent::Seeked { + track_id, + position_ms, + .. + } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Seeked: Invalid track id: {e}"), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "seeked".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("POSITION_MS", position_ms.to_string()); + } + }, + PlayerEvent::PositionCorrection { + track_id, + position_ms, + .. + } => match track_id.to_id() { + Err(e) => { + warn!("PlayerEvent::PositionCorrection: Invalid track id: {e}") + } + Ok(id) => { + env_vars + .insert("PLAYER_EVENT", "position_correction".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("POSITION_MS", position_ms.to_string()); + } + }, + PlayerEvent::SessionConnected { + connection_id, + user_name, + } => { + env_vars.insert("PLAYER_EVENT", "session_connected".to_string()); + env_vars.insert("CONNECTION_ID", connection_id); + env_vars.insert("USER_NAME", user_name); + } + PlayerEvent::SessionDisconnected { + connection_id, + user_name, + } => { + env_vars.insert("PLAYER_EVENT", "session_disconnected".to_string()); + env_vars.insert("CONNECTION_ID", connection_id); + env_vars.insert("USER_NAME", user_name); + } + PlayerEvent::SessionClientChanged { + client_id, + client_name, + client_brand_name, + client_model_name, + } => { + env_vars + .insert("PLAYER_EVENT", "session_client_changed".to_string()); + env_vars.insert("CLIENT_ID", client_id); + env_vars.insert("CLIENT_NAME", client_name); + env_vars.insert("CLIENT_BRAND_NAME", client_brand_name); + env_vars.insert("CLIENT_MODEL_NAME", client_model_name); + } + PlayerEvent::ShuffleChanged { shuffle } => { + env_vars.insert("PLAYER_EVENT", "shuffle_changed".to_string()); + env_vars.insert("SHUFFLE", shuffle.to_string()); + } + PlayerEvent::RepeatChanged { context, track } => { + env_vars.insert("PLAYER_EVENT", "repeat_changed".to_string()); + env_vars.insert("REPEAT", context.to_string()); + env_vars.insert("REPEAT_TRACK", track.to_string()); + } + PlayerEvent::AutoPlayChanged { auto_play } => { + env_vars.insert("PLAYER_EVENT", "auto_play_changed".to_string()); + env_vars.insert("AUTO_PLAY", auto_play.to_string()); + } + + PlayerEvent::FilterExplicitContentChanged { filter } => { + env_vars.insert( + "PLAYER_EVENT", + "filter_explicit_content_changed".to_string(), + ); + env_vars.insert("FILTER", filter.to_string()); + } + // Ignore event irrelevant for standalone binary like PositionChanged + _ => {} + } + + if !env_vars.is_empty() { + run_program(env_vars, &on_event); + } + } + } + } + })); + + Self { thread_handle } + } +} + +impl Drop for EventHandler { + fn drop(&mut self) { + debug!("Shutting down EventHandler thread ..."); + if let Some(handle) = self.thread_handle.take() { + if let Err(e) = handle.join() { + error!("EventHandler thread Error: {e:?}"); + } + } + } +} + +pub fn run_program_on_sink_events(sink_status: SinkStatus, onevent: &str) { let mut env_vars = HashMap::new(); + env_vars.insert("PLAYER_EVENT", "sink".to_string()); + let sink_status = match sink_status { SinkStatus::Running => "running", SinkStatus::TemporarilyClosed => "temporarily_closed", SinkStatus::Closed => "closed", }; - env_vars.insert("SINK_STATUS", sink_status.to_string()); - let mut v: Vec<&str> = onevent.split_whitespace().collect(); - info!("Running {:?} with environment variables {:?}", v, env_vars); - Command::new(&v.remove(0)) + env_vars.insert("SINK_STATUS", sink_status.to_string()); + + run_program(env_vars, onevent); +} + +fn run_program(env_vars: HashMap<&str, String>, onevent: &str) { + let mut v: Vec<&str> = onevent.split_whitespace().collect(); + + debug!("Running {onevent} with environment variables:\n{env_vars:#?}"); + + match Command::new(v.remove(0)) .args(&v) .envs(env_vars.iter()) - .spawn()? - .wait() + .spawn() + { + Err(e) => warn!("On event program {onevent} failed to start: {e}"), + Ok(mut child) => match child.wait() { + Err(e) => warn!("On event program {onevent} failed: {e}"), + Ok(e) if e.success() => (), + Ok(e) => { + if let Some(code) = e.code() { + warn!("On event program {onevent} returned exit code {code}"); + } else { + warn!("On event program {onevent} returned failure: {e}"); + } + } + }, + } } diff --git a/test.sh b/test.sh new file mode 100755 index 00000000..df0e6d3c --- /dev/null +++ b/test.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +set -e + +clean() { + # some shells will call EXIT after the INT signal + # causing EXIT trap to be executed, so we trap EXIT after INT + trap '' EXIT + + cargo clean +} + +trap clean INT QUIT TERM EXIT + +# this script runs the tests and checks that also run as part of the`test.yml` github action workflow +cargo clean + +cargo fmt --all -- --check + +cargo hack clippy -p librespot-protocol --each-feature + +cargo hack clippy -p librespot --each-feature --exclude-all-features --include-features native-tls --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots +cargo hack clippy -p librespot --each-feature --exclude-all-features --include-features rustls-tls-native-roots --exclude-features native-tls,rustls-tls-webpki-roots +cargo hack clippy -p librespot --each-feature --exclude-all-features --include-features rustls-tls-webpki-roots --exclude-features native-tls,rustls-tls-native-roots + + +cargo fetch --locked +cargo build --frozen --workspace --examples +cargo test --workspace + +cargo hack check -p librespot-protocol --each-feature +cargo hack check -p librespot --each-feature --exclude-all-features --include-features native-tls --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots +cargo hack check -p librespot --each-feature --exclude-all-features --include-features rustls-tls-native-roots --exclude-features native-tls,rustls-tls-webpki-roots +run: cargo build --frozen